use std::path::{Path, PathBuf};
use std::{
collections::{VecDeque, hash_map::DefaultHasher},
hash::Hasher,
};
use crate::core::{
ClickAction, DialogMode, ExtensionPolicy, FileDialogError, FileFilter, SavePolicy, Selection,
SortBy, SortMode,
};
use crate::fs::{FileSystem, FsEntry};
use crate::places::Places;
use indexmap::IndexSet;
use regex::RegexBuilder;
#[cfg(feature = "tracing")]
use tracing::trace;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Modifiers {
pub ctrl: bool,
pub shift: bool,
}
#[derive(Clone, Debug)]
pub enum CoreEvent {
SelectAll,
MoveFocus {
delta: i32,
modifiers: Modifiers,
},
ClickEntry {
id: EntryId,
modifiers: Modifiers,
},
DoubleClickEntry {
id: EntryId,
},
SelectByPrefix(String),
ActivateFocused,
NavigateUp,
NavigateTo(PathBuf),
NavigateBack,
NavigateForward,
Refresh,
FocusAndSelectById(EntryId),
ReplaceSelectionByIds(Vec<EntryId>),
ClearSelection,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CoreEventOutcome {
None,
RequestConfirm,
}
#[derive(Clone, Debug)]
pub struct ConfirmGate {
pub can_confirm: bool,
pub message: Option<String>,
}
impl Default for ConfirmGate {
fn default() -> Self {
Self {
can_confirm: true,
message: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct EntryId(u64);
impl EntryId {
fn new(value: u64) -> Self {
Self(value)
}
pub fn from_path(path: &Path) -> Self {
let mut hasher = DefaultHasher::new();
hasher.write(path.to_string_lossy().as_bytes());
Self::new(hasher.finish())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScanHookAction {
Keep,
Drop,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScanPolicy {
Sync,
Incremental {
batch_entries: usize,
max_batches_per_tick: usize,
},
}
impl Default for ScanPolicy {
fn default() -> Self {
Self::Sync
}
}
impl ScanPolicy {
pub const TUNED_BATCH_ENTRIES: usize = 512;
pub const TUNED_MAX_BATCHES_PER_TICK: usize = 2;
pub const fn tuned_incremental() -> Self {
Self::Incremental {
batch_entries: Self::TUNED_BATCH_ENTRIES,
max_batches_per_tick: Self::TUNED_MAX_BATCHES_PER_TICK,
}
}
fn normalized(self) -> Self {
match self {
Self::Sync => Self::Sync,
Self::Incremental {
batch_entries,
max_batches_per_tick,
} => Self::Incremental {
batch_entries: batch_entries.max(1),
max_batches_per_tick: max_batches_per_tick.max(1),
},
}
}
fn max_batches_per_tick(self) -> usize {
match self {
Self::Sync => usize::MAX,
Self::Incremental {
max_batches_per_tick,
..
} => max_batches_per_tick,
}
}
}
#[derive(Clone, Debug)]
pub struct ScanRequest {
pub generation: u64,
pub cwd: PathBuf,
pub scan_policy: ScanPolicy,
pub submitted_at: std::time::Instant,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScanBatch {
pub generation: u64,
pub kind: ScanBatchKind,
pub is_final: bool,
}
impl ScanBatch {
fn begin(generation: u64) -> Self {
Self {
generation,
kind: ScanBatchKind::Begin,
is_final: false,
}
}
fn entries(generation: u64, loaded: usize, is_final: bool) -> Self {
Self {
generation,
kind: ScanBatchKind::Entries { loaded },
is_final,
}
}
fn complete(generation: u64, loaded: usize) -> Self {
Self {
generation,
kind: ScanBatchKind::Complete { loaded },
is_final: true,
}
}
fn error(generation: u64, message: String) -> Self {
Self {
generation,
kind: ScanBatchKind::Error { message },
is_final: true,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ScanBatchKind {
Begin,
Entries {
loaded: usize,
},
Complete {
loaded: usize,
},
Error {
message: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ScanStatus {
Idle,
Scanning {
generation: u64,
},
Partial {
generation: u64,
loaded: usize,
},
Complete {
generation: u64,
loaded: usize,
},
Failed {
generation: u64,
message: String,
},
}
impl Default for ScanStatus {
fn default() -> Self {
Self::Idle
}
}
type ScanHookFn = dyn FnMut(&mut FsEntry) -> ScanHookAction + 'static;
struct ScanHook {
inner: Box<ScanHookFn>,
}
impl std::fmt::Debug for ScanHook {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ScanHook").finish_non_exhaustive()
}
}
impl ScanHook {
fn new<F>(hook: F) -> Self
where
F: FnMut(&mut FsEntry) -> ScanHookAction + 'static,
{
Self {
inner: Box::new(hook),
}
}
fn apply(&mut self, entry: &mut FsEntry) -> ScanHookAction {
(self.inner)(entry)
}
}
#[derive(Clone, Debug)]
pub struct FileMeta {
pub is_dir: bool,
pub is_symlink: bool,
pub size: Option<u64>,
pub modified: Option<std::time::SystemTime>,
}
#[derive(Clone, Debug)]
pub struct DirSnapshot {
pub cwd: PathBuf,
pub entry_count: usize,
pub(crate) entries: Vec<DirEntry>,
}
#[derive(Clone, Debug)]
pub(crate) struct DirEntry {
pub(crate) id: EntryId,
pub(crate) name: String,
pub(crate) path: PathBuf,
pub(crate) is_dir: bool,
pub(crate) is_symlink: bool,
pub(crate) size: Option<u64>,
pub(crate) modified: Option<std::time::SystemTime>,
}
impl DirEntry {
pub(crate) fn display_name(&self) -> String {
if self.is_dir {
format!("[{}]", self.name)
} else {
self.name.clone()
}
}
fn stable_id(&self) -> EntryId {
self.id
}
}
#[derive(Debug)]
enum ScanRuntime {
Sync(SyncScanRuntime),
Worker(WorkerScanRuntime),
}
impl Default for ScanRuntime {
fn default() -> Self {
Self::Sync(SyncScanRuntime::default())
}
}
impl ScanRuntime {
fn from_policy(policy: ScanPolicy) -> Self {
match policy {
ScanPolicy::Sync => Self::Sync(SyncScanRuntime::default()),
ScanPolicy::Incremental { batch_entries, .. } => {
Self::Worker(WorkerScanRuntime::new(batch_entries))
}
}
}
fn set_policy(&mut self, policy: ScanPolicy) {
match policy {
ScanPolicy::Sync => {
if !matches!(self, Self::Sync(_)) {
*self = Self::Sync(SyncScanRuntime::default());
}
}
ScanPolicy::Incremental { batch_entries, .. } => {
if let Self::Worker(runtime) = self {
runtime.set_batch_entries(batch_entries);
} else {
*self = Self::Worker(WorkerScanRuntime::new(batch_entries));
}
}
}
}
fn submit(&mut self, request: ScanRequest, result: std::io::Result<DirSnapshot>) {
match self {
Self::Sync(runtime) => runtime.submit(request, result),
Self::Worker(runtime) => runtime.submit(request, result),
}
}
fn poll_batch(&mut self) -> Option<RuntimeBatch> {
match self {
Self::Sync(runtime) => runtime.poll_batch(),
Self::Worker(runtime) => runtime.poll_batch(),
}
}
fn cancel_generation(&mut self, generation: u64) {
match self {
Self::Sync(runtime) => runtime.cancel_generation(generation),
Self::Worker(runtime) => runtime.cancel_generation(generation),
}
}
}
#[derive(Debug, Default)]
struct SyncScanRuntime {
batches: VecDeque<RuntimeBatch>,
}
impl SyncScanRuntime {
fn submit(&mut self, request: ScanRequest, result: std::io::Result<DirSnapshot>) {
self.batches.clear();
self.batches.push_back(RuntimeBatch {
generation: request.generation,
kind: RuntimeBatchKind::Begin {
cwd: request.cwd.clone(),
},
});
match result {
Ok(snapshot) => {
let loaded = snapshot.entry_count;
self.batches.push_back(RuntimeBatch {
generation: request.generation,
kind: RuntimeBatchKind::ReplaceSnapshot { snapshot },
});
self.batches.push_back(RuntimeBatch {
generation: request.generation,
kind: RuntimeBatchKind::Complete { loaded },
});
}
Err(err) => {
self.batches.push_back(RuntimeBatch {
generation: request.generation,
kind: RuntimeBatchKind::Error {
cwd: request.cwd,
message: err.to_string(),
},
});
}
}
}
fn poll_batch(&mut self) -> Option<RuntimeBatch> {
self.batches.pop_front()
}
fn cancel_generation(&mut self, generation: u64) {
self.batches.retain(|batch| batch.generation != generation);
}
}
#[derive(Debug)]
struct WorkerScanRuntime {
batches: VecDeque<RuntimeBatch>,
batch_entries: usize,
}
impl WorkerScanRuntime {
fn new(batch_entries: usize) -> Self {
Self {
batches: VecDeque::new(),
batch_entries: batch_entries.max(1),
}
}
fn set_batch_entries(&mut self, batch_entries: usize) {
self.batch_entries = batch_entries.max(1);
}
fn submit(&mut self, request: ScanRequest, result: std::io::Result<DirSnapshot>) {
self.batches.clear();
self.batches.push_back(RuntimeBatch {
generation: request.generation,
kind: RuntimeBatchKind::Begin {
cwd: request.cwd.clone(),
},
});
match result {
Ok(snapshot) => {
let DirSnapshot { cwd, entries, .. } = snapshot;
let total = entries.len();
let mut loaded = 0usize;
let mut chunk = Vec::with_capacity(self.batch_entries);
for entry in entries {
chunk.push(entry);
if chunk.len() >= self.batch_entries {
loaded += chunk.len();
self.batches.push_back(RuntimeBatch {
generation: request.generation,
kind: RuntimeBatchKind::AppendEntries {
cwd: cwd.clone(),
entries: std::mem::take(&mut chunk),
loaded,
},
});
}
}
if !chunk.is_empty() {
loaded += chunk.len();
self.batches.push_back(RuntimeBatch {
generation: request.generation,
kind: RuntimeBatchKind::AppendEntries {
cwd: cwd.clone(),
entries: chunk,
loaded,
},
});
}
self.batches.push_back(RuntimeBatch {
generation: request.generation,
kind: RuntimeBatchKind::Complete { loaded: total },
});
}
Err(err) => {
self.batches.push_back(RuntimeBatch {
generation: request.generation,
kind: RuntimeBatchKind::Error {
cwd: request.cwd,
message: err.to_string(),
},
});
}
}
}
fn poll_batch(&mut self) -> Option<RuntimeBatch> {
self.batches.pop_front()
}
fn cancel_generation(&mut self, generation: u64) {
self.batches.retain(|batch| batch.generation != generation);
}
}
#[derive(Debug)]
struct RuntimeBatch {
generation: u64,
kind: RuntimeBatchKind,
}
#[derive(Debug)]
enum RuntimeBatchKind {
Begin {
cwd: PathBuf,
},
ReplaceSnapshot {
snapshot: DirSnapshot,
},
AppendEntries {
cwd: PathBuf,
entries: Vec<DirEntry>,
loaded: usize,
},
Complete {
loaded: usize,
},
Error {
cwd: PathBuf,
message: String,
},
}
#[derive(Debug)]
pub struct FileDialogCore {
pub mode: DialogMode,
pub cwd: PathBuf,
selected_ids: IndexSet<EntryId>,
pub save_name: String,
filters: Vec<FileFilter>,
active_filter: Option<usize>,
filter_selection_mode: FilterSelectionMode,
pub click_action: ClickAction,
pub search: String,
pub sort_by: SortBy,
pub sort_ascending: bool,
pub sort_mode: SortMode,
pub dirs_first: bool,
pub allow_multi: bool,
pub max_selection: Option<usize>,
pub show_hidden: bool,
pub double_click: bool,
pub places: Places,
pub save_policy: SavePolicy,
result: Option<Result<Selection, FileDialogError>>,
pending_overwrite: Option<Selection>,
focused_id: Option<EntryId>,
selection_anchor_id: Option<EntryId>,
view_names: Vec<String>,
view_ids: Vec<EntryId>,
entries: Vec<DirEntry>,
scan_hook: Option<ScanHook>,
scan_policy: ScanPolicy,
scan_status: ScanStatus,
scan_generation: u64,
scan_started_at: Option<std::time::Instant>,
scan_runtime: ScanRuntime,
dir_snapshot: DirSnapshot,
dir_snapshot_dirty: bool,
last_view_key: Option<ViewKey>,
nav_back: VecDeque<PathBuf>,
nav_forward: VecDeque<PathBuf>,
nav_recent: VecDeque<PathBuf>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FilterSelectionMode {
AutoFirst,
Manual,
}
impl FileDialogCore {
pub fn new(mode: DialogMode) -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let nav_recent = VecDeque::from([cwd.clone()]);
Self {
mode,
cwd,
selected_ids: IndexSet::new(),
save_name: String::new(),
filters: Vec::new(),
active_filter: None,
filter_selection_mode: FilterSelectionMode::AutoFirst,
click_action: ClickAction::Select,
search: String::new(),
sort_by: SortBy::Name,
sort_ascending: true,
sort_mode: SortMode::default(),
dirs_first: true,
allow_multi: matches!(mode, DialogMode::OpenFiles),
max_selection: None,
show_hidden: false,
double_click: true,
places: Places::default(),
save_policy: SavePolicy::default(),
result: None,
pending_overwrite: None,
focused_id: None,
selection_anchor_id: None,
view_names: Vec::new(),
view_ids: Vec::new(),
entries: Vec::new(),
scan_hook: None,
scan_policy: ScanPolicy::default(),
scan_status: ScanStatus::default(),
scan_generation: 0,
scan_started_at: None,
scan_runtime: ScanRuntime::from_policy(ScanPolicy::default()),
dir_snapshot: DirSnapshot {
cwd: PathBuf::new(),
entry_count: 0,
entries: Vec::new(),
},
dir_snapshot_dirty: true,
last_view_key: None,
nav_back: VecDeque::new(),
nav_forward: VecDeque::new(),
nav_recent,
}
}
pub fn can_navigate_back(&self) -> bool {
!self.nav_back.is_empty()
}
pub fn can_navigate_forward(&self) -> bool {
!self.nav_forward.is_empty()
}
pub fn recent_paths(&self) -> impl Iterator<Item = &PathBuf> {
self.nav_recent.iter()
}
fn push_nav_back(&mut self, cwd: PathBuf) {
const NAV_HISTORY_MAX: usize = 64;
if self.nav_back.back() == Some(&cwd) {
return;
}
self.nav_back.push_back(cwd);
while self.nav_back.len() > NAV_HISTORY_MAX {
let _ = self.nav_back.pop_front();
}
}
fn push_nav_forward(&mut self, cwd: PathBuf) {
const NAV_HISTORY_MAX: usize = 64;
if self.nav_forward.back() == Some(&cwd) {
return;
}
self.nav_forward.push_back(cwd);
while self.nav_forward.len() > NAV_HISTORY_MAX {
let _ = self.nav_forward.pop_front();
}
}
fn navigate_to_with_history(&mut self, target: PathBuf) {
if target == self.cwd {
return;
}
let old = self.cwd.clone();
self.push_nav_back(old);
self.nav_forward.clear();
self.set_cwd(target);
self.record_recent_cwd();
}
fn navigate_back(&mut self) {
let Some(prev) = self.nav_back.pop_back() else {
return;
};
if prev == self.cwd {
return;
}
let old = self.cwd.clone();
self.push_nav_forward(old);
self.set_cwd(prev);
self.record_recent_cwd();
}
fn navigate_forward(&mut self) {
let Some(next) = self.nav_forward.pop_back() else {
return;
};
if next == self.cwd {
return;
}
let old = self.cwd.clone();
self.push_nav_back(old);
self.set_cwd(next);
self.record_recent_cwd();
}
fn record_recent_cwd(&mut self) {
const NAV_RECENT_MAX: usize = 24;
let cwd = self.cwd.clone();
if self.nav_recent.front() == Some(&cwd) {
return;
}
self.nav_recent.retain(|p| p != &cwd);
self.nav_recent.push_front(cwd);
while self.nav_recent.len() > NAV_RECENT_MAX {
let _ = self.nav_recent.pop_back();
}
}
pub fn filters(&self) -> &[FileFilter] {
&self.filters
}
pub fn set_filters(&mut self, filters: Vec<FileFilter>) {
self.filters = filters;
self.filter_selection_mode = FilterSelectionMode::AutoFirst;
self.normalize_active_filter();
self.last_view_key = None;
}
pub fn add_filter(&mut self, filter: impl Into<FileFilter>) {
self.filters.push(filter.into());
self.normalize_active_filter();
self.last_view_key = None;
}
pub fn extend_filters<I, F>(&mut self, filters: I)
where
I: IntoIterator<Item = F>,
F: Into<FileFilter>,
{
self.filters.extend(filters.into_iter().map(Into::into));
self.normalize_active_filter();
self.last_view_key = None;
}
pub fn clear_filters(&mut self) {
self.filters.clear();
self.active_filter = None;
self.filter_selection_mode = FilterSelectionMode::Manual;
self.last_view_key = None;
}
pub fn active_filter_index(&self) -> Option<usize> {
self.active_filter
}
pub fn active_filter(&self) -> Option<&FileFilter> {
self.active_filter.and_then(|i| self.filters.get(i))
}
pub fn set_active_filter_all(&mut self) {
self.active_filter = None;
self.filter_selection_mode = FilterSelectionMode::Manual;
self.last_view_key = None;
}
pub fn set_active_filter_index(&mut self, index: usize) -> bool {
if index >= self.filters.len() {
return false;
}
self.active_filter = Some(index);
self.filter_selection_mode = FilterSelectionMode::Manual;
self.last_view_key = None;
true
}
pub(crate) fn entries(&self) -> &[DirEntry] {
&self.entries
}
pub fn handle_event(&mut self, event: CoreEvent) -> CoreEventOutcome {
match event {
CoreEvent::SelectAll => {
self.select_all();
CoreEventOutcome::None
}
CoreEvent::MoveFocus { delta, modifiers } => {
self.move_focus(delta, modifiers);
CoreEventOutcome::None
}
CoreEvent::ClickEntry { id, modifiers } => {
self.click_entry(id, modifiers);
CoreEventOutcome::None
}
CoreEvent::DoubleClickEntry { id } => {
if self.double_click_entry(id) {
CoreEventOutcome::RequestConfirm
} else {
CoreEventOutcome::None
}
}
CoreEvent::SelectByPrefix(prefix) => {
self.select_by_prefix(&prefix);
CoreEventOutcome::None
}
CoreEvent::ActivateFocused => {
if self.activate_focused() {
CoreEventOutcome::RequestConfirm
} else {
CoreEventOutcome::None
}
}
CoreEvent::NavigateUp => {
self.navigate_up();
CoreEventOutcome::None
}
CoreEvent::NavigateTo(path) => {
self.navigate_to(path);
CoreEventOutcome::None
}
CoreEvent::NavigateBack => {
self.navigate_back();
CoreEventOutcome::None
}
CoreEvent::NavigateForward => {
self.navigate_forward();
CoreEventOutcome::None
}
CoreEvent::Refresh => {
self.invalidate_dir_cache();
CoreEventOutcome::None
}
CoreEvent::FocusAndSelectById(id) => {
self.focus_and_select_by_id(id);
CoreEventOutcome::None
}
CoreEvent::ReplaceSelectionByIds(ids) => {
self.replace_selection_by_ids(ids);
CoreEventOutcome::None
}
CoreEvent::ClearSelection => {
self.clear_selection();
CoreEventOutcome::None
}
}
}
pub fn invalidate_dir_cache(&mut self) {
self.dir_snapshot_dirty = true;
self.last_view_key = None;
}
pub fn scan_policy(&self) -> ScanPolicy {
self.scan_policy
}
pub fn set_scan_policy(&mut self, policy: ScanPolicy) {
let normalized = policy.normalized();
if self.scan_policy == normalized {
return;
}
self.scan_policy = normalized;
self.scan_runtime.set_policy(normalized);
self.invalidate_dir_cache();
}
pub fn scan_generation(&self) -> u64 {
self.scan_generation
}
pub fn scan_status(&self) -> &ScanStatus {
&self.scan_status
}
pub fn request_rescan(&mut self) {
self.invalidate_dir_cache();
}
pub fn set_scan_hook<F>(&mut self, hook: F)
where
F: FnMut(&mut FsEntry) -> ScanHookAction + 'static,
{
self.scan_hook = Some(ScanHook::new(hook));
self.invalidate_dir_cache();
}
pub fn clear_scan_hook(&mut self) {
if self.scan_hook.is_none() {
return;
}
self.scan_hook = None;
self.invalidate_dir_cache();
}
pub(crate) fn take_result(&mut self) -> Option<Result<Selection, FileDialogError>> {
self.result.take()
}
pub fn set_cwd(&mut self, cwd: PathBuf) {
self.cwd = cwd;
self.clear_selection();
}
pub fn focus_and_select_by_id(&mut self, id: EntryId) {
self.select_single_by_id(id);
}
pub fn replace_selection_by_ids<I>(&mut self, ids: I)
where
I: IntoIterator<Item = EntryId>,
{
self.selected_ids.clear();
for id in ids {
self.selected_ids.insert(id);
}
self.enforce_selection_cap();
let last = self.selected_ids.iter().next_back().copied();
self.focused_id = last;
self.selection_anchor_id = last;
}
pub fn clear_selection(&mut self) {
self.selected_ids.clear();
self.focused_id = None;
self.selection_anchor_id = None;
}
pub fn selected_len(&self) -> usize {
self.selected_ids.len()
}
pub fn has_selection(&self) -> bool {
!self.selected_ids.is_empty()
}
pub fn selected_entry_ids(&self) -> Vec<EntryId> {
self.selected_ids.iter().copied().collect()
}
pub fn selected_entry_paths(&self) -> Vec<PathBuf> {
self.selected_ids
.iter()
.filter_map(|id| self.entry_path_by_id(*id).map(Path::to_path_buf))
.collect()
}
pub fn selected_entry_counts(&self) -> (usize, usize) {
self.selected_ids
.iter()
.filter_map(|id| self.entry_by_id(*id))
.fold((0usize, 0usize), |(files, dirs), entry| {
if entry.is_dir {
(files, dirs + 1)
} else {
(files + 1, dirs)
}
})
}
pub fn entry_path_by_id(&self, id: EntryId) -> Option<&Path> {
self.entry_by_id(id).map(|entry| entry.path.as_path())
}
pub fn focused_entry_id(&self) -> Option<EntryId> {
self.focused_id
}
pub(crate) fn is_selected_id(&self, id: EntryId) -> bool {
self.selected_ids.contains(&id)
}
pub(crate) fn rescan_if_needed(&mut self, fs: &dyn FileSystem) {
self.normalize_active_filter();
self.refresh_dir_snapshot_if_needed(fs);
let key = ViewKey::new(self);
if self.last_view_key.as_ref() == Some(&key) {
return;
}
let rebuild_reason = if self.last_view_key.is_none() {
"snapshot_or_forced"
} else {
"view_inputs_changed"
};
let rebuild_started_at = std::time::Instant::now();
let mut entries = self.dir_snapshot.entries.clone();
filter_entries_in_place(
&mut entries,
self.mode,
self.show_hidden,
&self.filters,
self.active_filter,
&self.search,
);
let type_dots_to_extract = igfd_type_dots_to_extract(self.active_filter());
sort_entries_in_place(
&mut entries,
self.sort_by,
self.sort_ascending,
self.sort_mode,
self.dirs_first,
type_dots_to_extract,
);
self.view_names = entries.iter().map(|e| e.name.clone()).collect();
self.view_ids = entries.iter().map(DirEntry::stable_id).collect();
self.entries = entries;
self.retain_selected_visible();
self.last_view_key = Some(key);
trace_projector_rebuild(
rebuild_reason,
self.entries.len(),
rebuild_started_at.elapsed().as_micros(),
);
}
fn normalize_active_filter(&mut self) {
if self.filters.is_empty() {
self.active_filter = None;
return;
}
if matches!(self.mode, DialogMode::PickFolder) {
self.active_filter = None;
return;
}
match self.filter_selection_mode {
FilterSelectionMode::AutoFirst => {
let i = self.active_filter.unwrap_or(0);
self.active_filter = Some(i.min(self.filters.len().saturating_sub(1)));
}
FilterSelectionMode::Manual => {
if let Some(i) = self.active_filter {
if i >= self.filters.len() {
self.active_filter = Some(0);
}
}
}
}
}
fn refresh_dir_snapshot_if_needed(&mut self, fs: &dyn FileSystem) {
let cwd_changed = self.dir_snapshot.cwd != self.cwd;
let should_refresh = self.dir_snapshot_dirty || cwd_changed;
if should_refresh {
let request = self.begin_scan_request();
self.scan_runtime
.cancel_generation(request.generation.saturating_sub(1));
let scan_result =
read_entries_snapshot_with_fs(fs, &request.cwd, self.scan_hook.as_mut());
self.scan_runtime.submit(request, scan_result);
self.dir_snapshot_dirty = false;
}
let mut budget = self.scan_runtime_batch_budget();
while budget > 0 {
let Some(runtime_batch) = self.scan_runtime.poll_batch() else {
break;
};
self.apply_runtime_batch(runtime_batch);
budget = budget.saturating_sub(1);
}
}
fn scan_runtime_batch_budget(&self) -> usize {
self.scan_policy.max_batches_per_tick()
}
fn begin_scan_request(&mut self) -> ScanRequest {
let generation = self.scan_generation.saturating_add(1);
self.scan_generation = generation;
let request = ScanRequest {
generation,
cwd: self.cwd.clone(),
scan_policy: self.scan_policy,
submitted_at: std::time::Instant::now(),
};
trace_scan_requested(&request);
request
}
fn apply_runtime_batch(&mut self, runtime_batch: RuntimeBatch) {
if runtime_batch.generation != self.scan_generation {
trace_scan_dropped_stale_batch(
runtime_batch.generation,
self.scan_generation,
"runtime_batch",
);
return;
}
match runtime_batch.kind {
RuntimeBatchKind::Begin { cwd } => {
self.dir_snapshot = empty_snapshot_for_cwd(&cwd);
self.last_view_key = None;
self.apply_scan_batch(ScanBatch::begin(runtime_batch.generation));
trace_scan_batch_applied(runtime_batch.generation, 0, "begin");
}
RuntimeBatchKind::ReplaceSnapshot { snapshot } => {
let loaded = snapshot.entry_count;
self.dir_snapshot = snapshot;
self.last_view_key = None;
self.apply_scan_batch(ScanBatch::entries(runtime_batch.generation, loaded, false));
trace_scan_batch_applied(runtime_batch.generation, loaded, "replace_snapshot");
}
RuntimeBatchKind::AppendEntries {
cwd,
entries,
loaded,
} => {
if self.dir_snapshot.cwd != cwd {
self.dir_snapshot = empty_snapshot_for_cwd(&cwd);
}
let batch_entries = entries.len();
self.dir_snapshot.entries.extend(entries);
self.dir_snapshot.entry_count = self.dir_snapshot.entries.len();
self.last_view_key = None;
self.apply_scan_batch(ScanBatch::entries(runtime_batch.generation, loaded, false));
trace_scan_batch_applied(runtime_batch.generation, batch_entries, "append_entries");
}
RuntimeBatchKind::Complete { loaded } => {
self.last_view_key = None;
self.apply_scan_batch(ScanBatch::complete(runtime_batch.generation, loaded));
trace_scan_batch_applied(runtime_batch.generation, 0, "complete");
}
RuntimeBatchKind::Error { cwd, message } => {
self.dir_snapshot = empty_snapshot_for_cwd(&cwd);
self.last_view_key = None;
self.apply_scan_batch(ScanBatch::error(runtime_batch.generation, message));
trace_scan_batch_applied(runtime_batch.generation, 0, "error");
}
}
}
fn apply_scan_batch(&mut self, batch: ScanBatch) {
if batch.generation != self.scan_generation {
trace_scan_dropped_stale_batch(batch.generation, self.scan_generation, "scan_batch");
return;
}
self.scan_status = match batch.kind {
ScanBatchKind::Begin => {
self.scan_started_at = Some(std::time::Instant::now());
ScanStatus::Scanning {
generation: batch.generation,
}
}
ScanBatchKind::Entries { loaded } => {
if batch.is_final {
let duration_ms = self
.scan_started_at
.take()
.map(|started| started.elapsed().as_millis())
.unwrap_or(0);
trace_scan_completed(batch.generation, loaded, duration_ms);
ScanStatus::Complete {
generation: batch.generation,
loaded,
}
} else {
ScanStatus::Partial {
generation: batch.generation,
loaded,
}
}
}
ScanBatchKind::Complete { loaded } => {
let duration_ms = self
.scan_started_at
.take()
.map(|started| started.elapsed().as_millis())
.unwrap_or(0);
trace_scan_completed(batch.generation, loaded, duration_ms);
ScanStatus::Complete {
generation: batch.generation,
loaded,
}
}
ScanBatchKind::Error { message } => {
self.scan_started_at = None;
ScanStatus::Failed {
generation: batch.generation,
message,
}
}
};
}
fn entry_by_id(&self, id: EntryId) -> Option<&DirEntry> {
self.entries.iter().find(|entry| entry.id == id)
}
fn name_for_id(&self, id: EntryId) -> Option<&str> {
self.entry_by_id(id)
.map(|entry| entry.name.as_str())
.or_else(|| {
self.view_ids
.iter()
.position(|candidate| *candidate == id)
.and_then(|index| self.view_names.get(index).map(String::as_str))
})
}
pub(crate) fn select_by_prefix(&mut self, prefix: &str) {
let prefix = prefix.trim();
if prefix.is_empty() || self.view_ids.is_empty() {
return;
}
let prefix_lower = prefix.to_lowercase();
let len = self.view_ids.len();
let start_idx = self
.focused_id
.and_then(|focused| self.view_ids.iter().position(|id| *id == focused))
.map(|i| (i + 1) % len)
.unwrap_or(0);
for offset in 0..len {
let index = (start_idx + offset) % len;
let id = self.view_ids[index];
if self
.name_for_id(id)
.map(|name| name.to_lowercase().starts_with(&prefix_lower))
.unwrap_or(false)
{
self.select_single_by_id(id);
break;
}
}
}
pub(crate) fn select_all(&mut self) {
let cap = self.selection_cap();
if cap <= 1 {
return;
}
self.selected_ids.clear();
for id in self.view_ids.iter().take(cap).copied() {
self.selected_ids.insert(id);
}
let last = self.selected_ids.iter().next_back().copied();
self.focused_id = last;
self.selection_anchor_id = last;
}
pub(crate) fn move_focus(&mut self, delta: i32, modifiers: Modifiers) {
if self.view_ids.is_empty() {
return;
}
let len = self.view_ids.len();
let current_idx = self
.focused_id
.and_then(|id| self.view_ids.iter().position(|candidate| *candidate == id));
let next_idx = match current_idx {
Some(index) => {
let next = index as i32 + delta;
next.clamp(0, (len - 1) as i32) as usize
}
None => {
if delta >= 0 {
0
} else {
len - 1
}
}
};
let target_id = self.view_ids[next_idx];
if modifiers.shift {
let anchor_id = self
.selection_anchor_id
.or(self.focused_id)
.unwrap_or(target_id);
if self.selection_anchor_id.is_none() {
self.selection_anchor_id = Some(anchor_id);
}
if let Some(range) = select_range_by_id_capped(
&self.view_ids,
anchor_id,
target_id,
self.selection_cap(),
) {
self.selected_ids = range.into_iter().collect();
self.focused_id = Some(target_id);
} else {
self.select_single_by_id(target_id);
}
} else {
self.select_single_by_id(target_id);
}
}
pub(crate) fn activate_focused(&mut self) -> bool {
if self.selected_ids.is_empty() {
if let Some(id) = self.focused_id {
self.selected_ids.insert(id);
self.selection_anchor_id = Some(id);
}
}
!self.selected_ids.is_empty()
}
pub(crate) fn click_entry(&mut self, id: EntryId, modifiers: Modifiers) {
let Some(entry) = self.entry_by_id(id).cloned() else {
return;
};
if entry.is_dir {
match self.click_action {
ClickAction::Select => {
self.select_single_by_id(id);
}
ClickAction::Navigate => {
let target = self.cwd.join(&entry.name);
self.navigate_to_with_history(target);
}
}
return;
}
if modifiers.shift {
if let Some(anchor_id) = self.selection_anchor_id {
if let Some(range) =
select_range_by_id_capped(&self.view_ids, anchor_id, id, self.selection_cap())
{
self.selected_ids = range.into_iter().collect();
self.focused_id = Some(id);
return;
}
}
self.select_single_by_id(id);
return;
}
if self.selection_cap() <= 1 || !modifiers.ctrl {
self.select_single_by_id(id);
return;
}
toggle_select_id(&mut self.selected_ids, id);
self.focused_id = Some(id);
self.selection_anchor_id = Some(id);
self.enforce_selection_cap();
}
pub(crate) fn double_click_entry(&mut self, id: EntryId) -> bool {
if !self.double_click {
return false;
}
let Some(entry) = self.entry_by_id(id).cloned() else {
return false;
};
if entry.is_dir {
let target = self.cwd.join(&entry.name);
self.navigate_to_with_history(target);
return false;
}
if matches!(self.mode, DialogMode::OpenFile | DialogMode::OpenFiles) {
self.select_single_by_id(id);
return true;
}
false
}
pub(crate) fn navigate_up(&mut self) {
let mut target = self.cwd.clone();
if !target.pop() {
return;
}
self.navigate_to_with_history(target);
}
pub(crate) fn navigate_to(&mut self, p: PathBuf) {
self.navigate_to_with_history(p);
}
pub(crate) fn confirm(
&mut self,
fs: &dyn FileSystem,
gate: &ConfirmGate,
typed_footer_name: Option<&str>,
) -> Result<(), FileDialogError> {
self.normalize_active_filter();
self.result = None;
self.pending_overwrite = None;
let selected_entries = self
.selected_ids
.iter()
.filter_map(|id| self.entry_by_id(*id).cloned())
.collect::<Vec<_>>();
if matches!(self.mode, DialogMode::OpenFile | DialogMode::OpenFiles)
&& selected_entries.len() == 1
&& selected_entries[0].is_dir
{
let target = self.cwd.join(&selected_entries[0].name);
self.navigate_to_with_history(target);
return Ok(());
}
if !gate.can_confirm {
let msg = gate
.message
.clone()
.unwrap_or_else(|| "validation blocked".to_string());
return Err(FileDialogError::ValidationBlocked(msg));
}
if matches!(self.mode, DialogMode::OpenFile | DialogMode::OpenFiles)
&& selected_entries.is_empty()
{
if let Some(typed) = typed_footer_name.map(str::trim) {
if !typed.is_empty() {
let raw = PathBuf::from(typed);
let raw = if raw.is_absolute() {
raw
} else {
self.cwd.join(&raw)
};
let p = fs.canonicalize(&raw).unwrap_or(raw.clone());
match fs.metadata(&p) {
Ok(md) => {
if md.is_dir {
self.navigate_to_with_history(p);
return Ok(());
}
self.result = Some(Ok(Selection { paths: vec![p] }));
return Ok(());
}
Err(e) => {
use std::io::ErrorKind::*;
let msg = match e.kind() {
NotFound => format!("No such file: {}", typed),
PermissionDenied => format!("Permission denied: {}", typed),
_ => format!("Invalid file '{}': {}", typed, e),
};
return Err(FileDialogError::InvalidPath(msg));
}
}
}
}
}
let sel = finalize_selection(
self.mode,
&self.cwd,
selected_entries,
&self.save_name,
&self.filters,
self.active_filter,
&self.save_policy,
)?;
if matches!(self.mode, DialogMode::SaveFile) {
let target = sel
.paths
.get(0)
.cloned()
.unwrap_or_else(|| self.cwd.clone());
match fs.metadata(&target) {
Ok(md) => {
if md.is_dir {
return Err(FileDialogError::InvalidPath(
"file name points to a directory".into(),
));
}
if self.save_policy.confirm_overwrite {
self.pending_overwrite = Some(sel);
return Ok(());
}
}
Err(_) => {}
}
}
self.result = Some(Ok(sel));
Ok(())
}
pub(crate) fn cancel(&mut self) {
self.result = Some(Err(FileDialogError::Cancelled));
}
pub(crate) fn pending_overwrite(&self) -> Option<&Selection> {
self.pending_overwrite.as_ref()
}
pub(crate) fn accept_overwrite(&mut self) {
if let Some(sel) = self.pending_overwrite.take() {
self.result = Some(Ok(sel));
}
}
pub(crate) fn cancel_overwrite(&mut self) {
self.pending_overwrite = None;
}
fn select_single_by_id(&mut self, id: EntryId) {
self.selected_ids.clear();
self.selected_ids.insert(id);
self.focused_id = Some(id);
self.selection_anchor_id = Some(id);
}
fn selection_cap(&self) -> usize {
if !self.allow_multi {
return 1;
}
self.max_selection.unwrap_or(usize::MAX).max(1)
}
fn enforce_selection_cap(&mut self) {
let cap = self.selection_cap();
if cap == usize::MAX || self.selected_ids.len() <= cap {
return;
}
while self.selected_ids.len() > cap {
let Some(first) = self.selected_ids.iter().next().copied() else {
break;
};
self.selected_ids.shift_remove(&first);
}
}
fn retain_selected_visible(&mut self) {
if self.selected_ids.is_empty() {
return;
}
let allow_unresolved = self.allow_unresolved_selection();
if !allow_unresolved {
self.selected_ids
.retain(|id| self.view_ids.iter().any(|visible| visible == id));
self.enforce_selection_cap();
}
if self.view_ids.is_empty() {
if !allow_unresolved {
self.focused_id = None;
self.selection_anchor_id = None;
}
return;
}
if !allow_unresolved
&& self
.focused_id
.map(|id| self.view_ids.iter().all(|visible| *visible != id))
.unwrap_or(false)
{
self.focused_id = self.selected_ids.iter().next_back().copied();
}
if !allow_unresolved
&& self
.selection_anchor_id
.map(|id| self.view_ids.iter().all(|visible| *visible != id))
.unwrap_or(false)
{
self.selection_anchor_id = self.focused_id;
}
}
fn allow_unresolved_selection(&self) -> bool {
matches!(
self.scan_status,
ScanStatus::Scanning { .. } | ScanStatus::Partial { .. }
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ViewKey {
cwd: PathBuf,
mode: DialogMode,
show_hidden: bool,
search: String,
sort_by: SortBy,
sort_ascending: bool,
sort_mode: SortMode,
dirs_first: bool,
active_filter_hash: u64,
}
impl ViewKey {
fn new(core: &FileDialogCore) -> Self {
Self {
cwd: core.cwd.clone(),
mode: core.mode,
show_hidden: core.show_hidden,
search: core.search.clone(),
sort_by: core.sort_by,
sort_ascending: core.sort_ascending,
sort_mode: core.sort_mode,
dirs_first: core.dirs_first,
active_filter_hash: active_filter_hash(&core.filters, core.active_filter),
}
}
}
fn active_filter_hash(filters: &[FileFilter], active_filter: Option<usize>) -> u64 {
let Some(i) = active_filter else {
return 0;
};
let Some(f) = filters.get(i) else {
return 0;
};
let mut hasher = DefaultHasher::new();
hasher.write(f.name.as_bytes());
for t in &f.extensions {
hasher.write(t.as_bytes());
hasher.write_u8(0);
}
hasher.finish()
}
fn effective_filters(filters: &[FileFilter], active_filter: Option<usize>) -> Vec<FileFilter> {
match active_filter {
Some(i) => filters.get(i).cloned().into_iter().collect(),
None => Vec::new(),
}
}
#[derive(Debug)]
enum FilterMatcher {
Any,
Extension(String),
ExtensionGlob(String),
NameRegex(regex::Regex),
}
fn compile_filter_matchers(filters: &[FileFilter]) -> Vec<FilterMatcher> {
let mut out = Vec::new();
for f in filters {
for token in &f.extensions {
let t = token.trim();
if t.is_empty() {
continue;
}
if let Some(re) = parse_regex_token(t) {
let built = RegexBuilder::new(re)
.case_insensitive(true)
.build()
.map(FilterMatcher::NameRegex);
if let Ok(m) = built {
out.push(m);
}
continue;
}
if t == "*" {
out.push(FilterMatcher::Any);
continue;
}
if t.contains('*') || t.contains('?') {
let p = normalize_extension_glob(t);
out.push(FilterMatcher::ExtensionGlob(p));
continue;
}
if let Some(ext) = plain_extension_token(t) {
out.push(FilterMatcher::Extension(ext.to_string()));
}
}
}
out
}
#[cfg(test)]
fn matches_filters(name: &str, filters: &[FileFilter]) -> bool {
let matchers = compile_filter_matchers(filters);
matches_filter_matchers(name, &matchers)
}
fn matches_filter_matchers(name: &str, matchers: &[FilterMatcher]) -> bool {
if matchers.is_empty() {
return true;
}
let name_lower = name.to_lowercase();
let ext_full = full_extension_lower(&name_lower);
matchers.iter().any(|m| match m {
FilterMatcher::Any => true,
FilterMatcher::Extension(ext) => has_extension_suffix(&name_lower, ext),
FilterMatcher::ExtensionGlob(pat) => wildcard_match(pat.as_str(), ext_full),
FilterMatcher::NameRegex(re) => re.is_match(name),
})
}
fn parse_regex_token(token: &str) -> Option<&str> {
let t = token.trim();
if t.starts_with("((") && t.ends_with("))") && t.len() >= 4 {
Some(&t[2..t.len() - 2])
} else {
None
}
}
fn plain_extension_token(token: &str) -> Option<&str> {
let t = token.trim().trim_start_matches('.');
if t.is_empty() {
return None;
}
if parse_regex_token(t).is_some() {
return None;
}
if t.contains('*') || t.contains('?') {
return None;
}
Some(t)
}
fn normalize_extension_glob(token: &str) -> String {
let t = token.trim().to_lowercase();
if t.starts_with('.') || t.starts_with('*') || t.starts_with('?') {
t
} else {
format!(".{t}")
}
}
fn full_extension_lower(name_lower: &str) -> &str {
name_lower.find('.').map(|i| &name_lower[i..]).unwrap_or("")
}
fn wildcard_match(pattern: &str, text: &str) -> bool {
let p = pattern.as_bytes();
let t = text.as_bytes();
let (mut pi, mut ti) = (0usize, 0usize);
let mut star_pi: Option<usize> = None;
let mut star_ti: usize = 0;
while ti < t.len() {
if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
continue;
}
if pi < p.len() && p[pi] == b'*' {
star_pi = Some(pi);
pi += 1;
star_ti = ti;
continue;
}
if let Some(sp) = star_pi {
pi = sp + 1;
star_ti += 1;
ti = star_ti;
continue;
}
return false;
}
while pi < p.len() && p[pi] == b'*' {
pi += 1;
}
pi == p.len()
}
fn has_extension_suffix(name_lower: &str, ext: &str) -> bool {
let ext = ext.trim_start_matches('.');
if ext.is_empty() {
return false;
}
if !name_lower.ends_with(ext) {
return false;
}
let prefix_len = name_lower.len() - ext.len();
if prefix_len == 0 {
return false;
}
name_lower.as_bytes()[prefix_len - 1] == b'.'
}
fn toggle_select_id(list: &mut IndexSet<EntryId>, id: EntryId) {
if !list.shift_remove(&id) {
list.insert(id);
}
}
fn select_range_by_id_capped(
view_ids: &[EntryId],
anchor: EntryId,
target: EntryId,
cap: usize,
) -> Option<Vec<EntryId>> {
let ia = view_ids.iter().position(|id| *id == anchor)?;
let it = view_ids.iter().position(|id| *id == target)?;
let (lo, hi) = if ia <= it { (ia, it) } else { (it, ia) };
let mut range = view_ids[lo..=hi].to_vec();
if cap != usize::MAX && range.len() > cap {
if it >= ia {
let start = range.len() - cap;
range = range[start..].to_vec();
} else {
range.truncate(cap);
}
}
Some(range)
}
fn finalize_selection(
mode: DialogMode,
cwd: &Path,
selected_entries: Vec<DirEntry>,
save_name: &str,
filters: &[FileFilter],
active_filter: Option<usize>,
save_policy: &SavePolicy,
) -> Result<Selection, FileDialogError> {
let mut sel = Selection { paths: Vec::new() };
let eff_filters = effective_filters(filters, active_filter);
let matchers = compile_filter_matchers(&eff_filters);
match mode {
DialogMode::PickFolder => {
if let Some(dir) = selected_entries.into_iter().find(|e| e.is_dir) {
sel.paths.push(dir.path);
} else {
sel.paths.push(cwd.to_path_buf());
}
}
DialogMode::OpenFile | DialogMode::OpenFiles => {
if selected_entries.is_empty() {
return Err(FileDialogError::InvalidPath("no selection".into()));
}
for entry in selected_entries {
if entry.is_dir {
continue;
}
if !matches_filter_matchers(&entry.name, &matchers) {
continue;
}
sel.paths.push(entry.path);
}
if sel.paths.is_empty() {
return Err(FileDialogError::InvalidPath(
"no file matched filters".into(),
));
}
}
DialogMode::SaveFile => {
let name = normalize_save_name(save_name, &eff_filters, save_policy.extension_policy);
if name.is_empty() {
return Err(FileDialogError::InvalidPath("empty file name".into()));
}
sel.paths.push(cwd.join(name));
}
}
Ok(sel)
}
fn normalize_save_name(save_name: &str, filters: &[FileFilter], policy: ExtensionPolicy) -> String {
let name = save_name.trim().to_string();
if name.is_empty() {
return name;
}
let default_ext = filters
.first()
.and_then(|f| f.extensions.iter().find_map(|s| plain_extension_token(s)))
.map(|s| s.trim_start_matches('.'));
let Some(default_ext) = default_ext else {
return name;
};
let p = Path::new(&name);
let has_ext = p.extension().and_then(|s| s.to_str()).is_some();
match policy {
ExtensionPolicy::KeepUser => name,
ExtensionPolicy::AddIfMissing => {
if has_ext {
name
} else {
format!("{name}.{default_ext}")
}
}
ExtensionPolicy::ReplaceByFilter => {
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(&name);
format!("{stem}.{default_ext}")
}
}
}
fn filter_entries_in_place(
entries: &mut Vec<DirEntry>,
mode: DialogMode,
show_hidden: bool,
filters: &[FileFilter],
active_filter: Option<usize>,
search: &str,
) {
let display_filters = effective_filters(filters, active_filter);
let matchers = compile_filter_matchers(&display_filters);
let search_lower = if search.is_empty() {
None
} else {
Some(search.to_lowercase())
};
entries.retain(|e| {
if !show_hidden && e.name.starts_with('.') {
return false;
}
let pass_kind = if matches!(mode, DialogMode::PickFolder) {
e.is_dir
} else {
e.is_dir || matches_filter_matchers(&e.name, &matchers)
};
let pass_search = match &search_lower {
None => true,
Some(q) => e.name.to_lowercase().contains(q),
};
pass_kind && pass_search
});
}
fn sort_entries_in_place(
entries: &mut [DirEntry],
sort_by: SortBy,
sort_ascending: bool,
sort_mode: SortMode,
dirs_first: bool,
type_dots_to_extract: usize,
) {
entries.sort_by(|a, b| {
if dirs_first && a.is_dir != b.is_dir {
return b.is_dir.cmp(&a.is_dir);
}
let ord = match sort_by {
SortBy::Name => {
let al = a.name.to_lowercase();
let bl = b.name.to_lowercase();
cmp_lower(&al, &bl, sort_mode)
}
SortBy::Type => {
use std::cmp::Ordering;
let al = a.name.to_lowercase();
let bl = b.name.to_lowercase();
let ae = type_extension_lower(&al, type_dots_to_extract);
let be = type_extension_lower(&bl, type_dots_to_extract);
let ord = cmp_lower(ae, be, sort_mode);
if ord == Ordering::Equal {
cmp_lower(&al, &bl, sort_mode)
} else {
ord
}
}
SortBy::Extension => {
use std::cmp::Ordering;
let al = a.name.to_lowercase();
let bl = b.name.to_lowercase();
let ae = full_extension_lower(&al);
let be = full_extension_lower(&bl);
let ord = cmp_lower(ae, be, sort_mode);
if ord == Ordering::Equal {
cmp_lower(&al, &bl, sort_mode)
} else {
ord
}
}
SortBy::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)),
SortBy::Modified => a.modified.cmp(&b.modified),
};
if sort_ascending { ord } else { ord.reverse() }
});
}
fn igfd_type_dots_to_extract(active_filter: Option<&FileFilter>) -> usize {
let Some(filter) = active_filter else {
return 1;
};
let mut max_dots = 1usize;
for token in &filter.extensions {
let t = token.trim();
if t.is_empty() {
continue;
}
if parse_regex_token(t).is_some() {
continue;
}
let dot_count = t.as_bytes().iter().filter(|&&b| b == b'.').count();
let token_dots = if t.contains('*') || t.contains('?') {
dot_count
} else if t.starts_with('.') {
dot_count
} else {
dot_count.saturating_add(1)
};
max_dots = max_dots.max(token_dots);
}
max_dots.max(1)
}
fn type_extension_lower(name_lower: &str, dots_to_extract: usize) -> &str {
if dots_to_extract == 0 {
return full_extension_lower(name_lower);
}
let bytes = name_lower.as_bytes();
let total_dots = bytes.iter().filter(|&&b| b == b'.').count();
if total_dots == 0 {
return "";
}
let dots = dots_to_extract.min(total_dots);
let mut seen = 0usize;
for i in (0..bytes.len()).rev() {
if bytes[i] == b'.' {
seen += 1;
if seen == dots {
return &name_lower[i..];
}
}
}
""
}
fn cmp_lower(a: &str, b: &str, mode: SortMode) -> std::cmp::Ordering {
match mode {
SortMode::Natural => natural_cmp_lower(a, b),
SortMode::Lexicographic => a.cmp(b),
}
}
pub(crate) fn natural_cmp_lower(a: &str, b: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let ab = a.as_bytes();
let bb = b.as_bytes();
let (mut i, mut j) = (0usize, 0usize);
while i < ab.len() && j < bb.len() {
let ca = ab[i];
let cb = bb[j];
if ca.is_ascii_digit() && cb.is_ascii_digit() {
let (a_end, a_trim, a_trim_end) = scan_number(ab, i);
let (b_end, b_trim, b_trim_end) = scan_number(bb, j);
let a_len = a_trim_end.saturating_sub(a_trim);
let b_len = b_trim_end.saturating_sub(b_trim);
let ord = match a_len.cmp(&b_len) {
Ordering::Equal => ab[a_trim..a_trim_end].cmp(&bb[b_trim..b_trim_end]),
o => o,
};
if ord != Ordering::Equal {
return ord;
}
let ord = (a_end - i).cmp(&(b_end - j));
if ord != Ordering::Equal {
return ord;
}
i = a_end;
j = b_end;
continue;
}
if ca != cb {
return ca.cmp(&cb);
}
i += 1;
j += 1;
}
a.len().cmp(&b.len())
}
fn scan_number(bytes: &[u8], start: usize) -> (usize, usize, usize) {
let mut end = start;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
let mut trim = start;
while trim < end && bytes[trim] == b'0' {
trim += 1;
}
(end, trim, end)
}
fn entry_id_from_path(path: &Path, is_dir: bool, is_symlink: bool) -> EntryId {
let mut hasher = DefaultHasher::new();
hasher.write(path.to_string_lossy().as_bytes());
hasher.write_u8(if is_dir { 1 } else { 0 });
hasher.write_u8(if is_symlink { 1 } else { 0 });
EntryId::new(hasher.finish())
}
fn sanitize_scanned_entry(mut entry: FsEntry, dir: &Path) -> Option<FsEntry> {
if entry.path.as_os_str().is_empty() {
if entry.name.trim().is_empty() {
return None;
}
entry.path = dir.join(&entry.name);
}
if entry.name.trim().is_empty() {
let inferred_name = entry
.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.filter(|n| !n.is_empty())?;
entry.name = inferred_name;
}
if entry.is_dir {
entry.size = None;
}
Some(entry)
}
fn read_entries_snapshot_with_fs(
fs: &dyn FileSystem,
dir: &Path,
mut scan_hook: Option<&mut ScanHook>,
) -> std::io::Result<DirSnapshot> {
let mut out = Vec::new();
let rd = fs.read_dir(dir)?;
for mut entry in rd {
if let Some(hook) = scan_hook.as_deref_mut() {
if matches!(hook.apply(&mut entry), ScanHookAction::Drop) {
continue;
}
}
let Some(entry) = sanitize_scanned_entry(entry, dir) else {
continue;
};
let meta = FileMeta {
is_dir: entry.is_dir,
is_symlink: entry.is_symlink,
size: entry.size,
modified: entry.modified,
};
out.push(DirEntry {
id: entry_id_from_path(&entry.path, meta.is_dir, meta.is_symlink),
name: entry.name,
path: entry.path,
is_dir: meta.is_dir,
is_symlink: meta.is_symlink,
size: meta.size,
modified: meta.modified,
});
}
Ok(DirSnapshot {
cwd: dir.to_path_buf(),
entry_count: out.len(),
entries: out,
})
}
fn empty_snapshot_for_cwd(cwd: &Path) -> DirSnapshot {
DirSnapshot {
cwd: cwd.to_path_buf(),
entry_count: 0,
entries: Vec::new(),
}
}
#[cfg(feature = "tracing")]
fn trace_scan_requested(request: &ScanRequest) {
trace!(
event = "scan.requested",
generation = request.generation,
cwd = %request.cwd.display(),
?request.scan_policy,
"scan requested"
);
}
#[cfg(not(feature = "tracing"))]
fn trace_scan_requested(_request: &ScanRequest) {}
#[cfg(feature = "tracing")]
fn trace_scan_batch_applied(generation: u64, entries: usize, kind: &'static str) {
trace!(
event = "scan.batch_applied",
generation, entries, kind, "scan batch applied"
);
}
#[cfg(not(feature = "tracing"))]
fn trace_scan_batch_applied(_generation: u64, _entries: usize, _kind: &'static str) {}
#[cfg(feature = "tracing")]
fn trace_scan_completed(generation: u64, total_entries: usize, duration_ms: u128) {
trace!(
event = "scan.completed",
generation, total_entries, duration_ms, "scan completed"
);
}
#[cfg(not(feature = "tracing"))]
fn trace_scan_completed(_generation: u64, _total_entries: usize, _duration_ms: u128) {}
#[cfg(feature = "tracing")]
fn trace_scan_dropped_stale_batch(generation: u64, current_generation: u64, source: &'static str) {
trace!(
event = "scan.dropped_stale_batch",
generation, current_generation, source, "scan dropped stale batch"
);
}
#[cfg(not(feature = "tracing"))]
fn trace_scan_dropped_stale_batch(
_generation: u64,
_current_generation: u64,
_source: &'static str,
) {
}
#[cfg(feature = "tracing")]
fn trace_projector_rebuild(reason: &'static str, visible_entries: usize, duration_us: u128) {
trace!(
event = "projector.rebuild",
reason, visible_entries, duration_us, "projector rebuilt"
);
}
#[cfg(not(feature = "tracing"))]
fn trace_projector_rebuild(_reason: &'static str, _visible_entries: usize, _duration_us: u128) {}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::StdFileSystem;
use std::cell::Cell;
use std::time::{Duration, Instant};
fn mods(ctrl: bool, shift: bool) -> Modifiers {
Modifiers { ctrl, shift }
}
fn make_file_entry(name: &str) -> DirEntry {
let path = PathBuf::from("/tmp").join(name);
DirEntry {
id: entry_id_from_path(&path, false, false),
name: name.to_string(),
path,
is_dir: false,
is_symlink: false,
size: None,
modified: None,
}
}
fn make_dir_entry(name: &str) -> DirEntry {
let path = PathBuf::from("/tmp").join(name);
DirEntry {
id: entry_id_from_path(&path, true, false),
name: name.to_string(),
path,
is_dir: true,
is_symlink: false,
size: None,
modified: None,
}
}
fn set_view_files(core: &mut FileDialogCore, names: &[&str]) {
core.entries = names.iter().map(|name| make_file_entry(name)).collect();
core.view_names = core
.entries
.iter()
.map(|entry| entry.name.clone())
.collect();
core.view_ids = core.entries.iter().map(|entry| entry.id).collect();
}
fn entry_id(core: &FileDialogCore, name: &str) -> EntryId {
core.entries
.iter()
.find(|entry| entry.name == name)
.map(|entry| entry.id)
.unwrap_or_else(|| panic!("missing entry id for {name}"))
}
fn selected_entry_names(core: &FileDialogCore) -> Vec<String> {
let entries = core.entries();
core.selected_entry_ids()
.into_iter()
.filter_map(|id| {
entries
.iter()
.find(|entry| entry.id == id)
.map(|entry| entry.name.clone())
})
.collect()
}
fn make_synthetic_fs_entries(count: usize) -> Vec<crate::fs::FsEntry> {
(0..count)
.map(|idx| {
let name = format!("file_{idx:05}.txt");
crate::fs::FsEntry {
path: PathBuf::from("/tmp").join(&name),
name,
is_dir: false,
is_symlink: false,
size: Some((idx % 1024) as u64),
modified: None,
}
})
.collect()
}
#[test]
fn navigation_history_back_forward_tracks_and_clears_forward() {
let mut core = FileDialogCore::new(DialogMode::OpenFile);
let start = PathBuf::from("/tmp").join("start");
let one = PathBuf::from("/tmp").join("one");
let two = PathBuf::from("/tmp").join("two");
core.set_cwd(start.clone());
assert!(!core.can_navigate_back());
assert!(!core.can_navigate_forward());
let _ = core.handle_event(CoreEvent::NavigateTo(one.clone()));
assert_eq!(core.cwd, one);
assert!(core.can_navigate_back());
assert!(!core.can_navigate_forward());
let _ = core.handle_event(CoreEvent::NavigateBack);
assert_eq!(core.cwd, start);
assert!(!core.can_navigate_back());
assert!(core.can_navigate_forward());
let _ = core.handle_event(CoreEvent::NavigateForward);
assert_eq!(core.cwd, one);
assert!(core.can_navigate_back());
assert!(!core.can_navigate_forward());
let _ = core.handle_event(CoreEvent::NavigateBack);
assert_eq!(core.cwd, start);
assert!(core.can_navigate_forward());
let _ = core.handle_event(CoreEvent::NavigateTo(two.clone()));
assert_eq!(core.cwd, two);
assert!(core.can_navigate_back());
assert!(!core.can_navigate_forward());
let _ = core.handle_event(CoreEvent::Refresh);
assert_eq!(core.cwd, two);
assert!(core.can_navigate_back());
assert!(!core.can_navigate_forward());
}
#[derive(Default)]
struct TestFs {
meta: std::collections::HashMap<PathBuf, crate::fs::FsMetadata>,
entries: Vec<crate::fs::FsEntry>,
read_dir_calls: Cell<usize>,
read_dir_error: Option<std::io::ErrorKind>,
}
impl crate::fs::FileSystem for TestFs {
fn read_dir(&self, _dir: &Path) -> std::io::Result<Vec<crate::fs::FsEntry>> {
self.read_dir_calls.set(self.read_dir_calls.get() + 1);
if let Some(kind) = self.read_dir_error {
return Err(std::io::Error::new(kind, "read_dir failure"));
}
Ok(self.entries.clone())
}
fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
Ok(path.to_path_buf())
}
fn metadata(&self, path: &Path) -> std::io::Result<crate::fs::FsMetadata> {
self.meta
.get(path)
.cloned()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found"))
}
fn create_dir(&self, _path: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"create_dir not supported in TestFs",
))
}
fn rename(&self, _from: &Path, _to: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"rename not supported in TestFs",
))
}
fn remove_file(&self, _path: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"remove_file not supported in TestFs",
))
}
fn remove_dir(&self, _path: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"remove_dir not supported in TestFs",
))
}
fn remove_dir_all(&self, _path: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"remove_dir_all not supported in TestFs",
))
}
fn copy_file(&self, _from: &Path, _to: &Path) -> std::io::Result<u64> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"copy_file not supported in TestFs",
))
}
}
#[test]
fn entry_path_by_id_resolves_visible_entry_path() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
set_view_files(&mut core, &["a.txt", "b.txt"]);
let b = entry_id(&core, "b.txt");
assert_eq!(core.entry_path_by_id(b), Some(Path::new("/tmp/b.txt")));
}
#[test]
fn entry_path_by_id_returns_none_for_unresolved_id() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
set_view_files(&mut core, &["a.txt"]);
let missing = entry_id_from_path(Path::new("/tmp/missing.txt"), false, false);
assert!(core.entry_path_by_id(missing).is_none());
}
#[test]
fn selected_entry_paths_skips_unresolved_ids() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
set_view_files(&mut core, &["a.txt", "b.txt"]);
let a = entry_id(&core, "a.txt");
let missing = entry_id_from_path(Path::new("/tmp/missing.txt"), false, false);
core.replace_selection_by_ids([a, missing]);
assert_eq!(
core.selected_entry_paths(),
vec![PathBuf::from("/tmp/a.txt")]
);
}
#[test]
fn selected_entry_counts_tracks_files_and_dirs() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.entries = vec![make_file_entry("a.txt"), make_dir_entry("folder")];
core.view_names = core
.entries
.iter()
.map(|entry| entry.name.clone())
.collect();
core.view_ids = core.entries.iter().map(|entry| entry.id).collect();
let a = entry_id(&core, "a.txt");
let folder = entry_id(&core, "folder");
let missing = entry_id_from_path(Path::new("/tmp/missing.txt"), false, false);
core.replace_selection_by_ids([a, folder, missing]);
assert_eq!(core.selected_entry_counts(), (1, 1));
}
#[test]
fn cancel_sets_result() {
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cancel();
assert!(matches!(
core.take_result(),
Some(Err(crate::FileDialogError::Cancelled))
));
}
#[test]
fn click_file_toggles_in_multi_select() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.allow_multi = true;
set_view_files(&mut core, &["a.txt"]);
let a = entry_id(&core, "a.txt");
core.click_entry(a, mods(true, false));
assert_eq!(selected_entry_names(&core), vec!["a.txt"]);
core.click_entry(a, mods(true, false));
assert!(selected_entry_names(&core).is_empty());
}
#[test]
fn focus_and_select_by_id_accepts_unresolved_entry_until_rescan() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.allow_multi = true;
let pending = EntryId::from_path(&core.cwd.join("new_folder"));
core.focus_and_select_by_id(pending);
assert_eq!(core.selected_entry_ids(), vec![pending]);
assert_eq!(core.focused_entry_id(), Some(pending));
assert!(selected_entry_names(&core).is_empty());
}
#[test]
fn focus_and_select_by_id_sets_focus_and_anchor() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.allow_multi = true;
set_view_files(&mut core, &["a.txt", "b.txt"]);
let id = entry_id(&core, "b.txt");
core.focus_and_select_by_id(id);
assert_eq!(core.selected_entry_ids(), vec![id]);
assert_eq!(core.focused_entry_id(), Some(id));
assert_eq!(selected_entry_names(&core), vec!["b.txt"]);
}
#[test]
fn shift_click_selects_a_range_in_view_order() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.allow_multi = true;
set_view_files(&mut core, &["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"]);
core.click_entry(entry_id(&core, "b.txt"), mods(false, false));
core.click_entry(entry_id(&core, "e.txt"), mods(false, true));
assert_eq!(
selected_entry_names(&core),
vec!["b.txt", "c.txt", "d.txt", "e.txt"]
);
}
#[test]
fn ctrl_a_selects_all_when_multi_select_enabled() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.allow_multi = true;
set_view_files(&mut core, &["a", "b", "c"]);
core.select_all();
assert_eq!(selected_entry_names(&core), vec!["a", "b", "c"]);
}
#[test]
fn ctrl_a_respects_max_selection_cap() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.allow_multi = true;
core.max_selection = Some(2);
set_view_files(&mut core, &["a", "b", "c"]);
core.select_all();
assert_eq!(selected_entry_names(&core), vec!["a", "b"]);
}
#[test]
fn shift_click_respects_max_selection_cap_and_keeps_target() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.allow_multi = true;
core.max_selection = Some(2);
set_view_files(&mut core, &["a", "b", "c", "d", "e"]);
core.click_entry(entry_id(&core, "b"), mods(false, false));
core.click_entry(entry_id(&core, "e"), mods(false, true));
assert_eq!(selected_entry_names(&core), vec!["d", "e"]);
core.click_entry(entry_id(&core, "d"), mods(false, false));
core.click_entry(entry_id(&core, "b"), mods(false, true));
assert_eq!(selected_entry_names(&core), vec!["b", "c"]);
}
#[test]
fn ctrl_click_caps_by_dropping_oldest_selected() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.allow_multi = true;
core.max_selection = Some(2);
set_view_files(&mut core, &["a", "b", "c"]);
core.click_entry(entry_id(&core, "a"), mods(false, false));
core.click_entry(entry_id(&core, "b"), mods(true, false));
assert_eq!(selected_entry_names(&core), vec!["a", "b"]);
core.click_entry(entry_id(&core, "c"), mods(true, false));
assert_eq!(selected_entry_names(&core), vec!["b", "c"]);
}
#[test]
fn move_focus_with_shift_extends_range() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.allow_multi = true;
set_view_files(&mut core, &["a", "b", "c", "d"]);
core.click_entry(entry_id(&core, "b"), mods(false, false));
core.move_focus(2, mods(false, true));
assert_eq!(selected_entry_names(&core), vec!["b", "c", "d"]);
assert_eq!(core.focused_entry_id(), Some(entry_id(&core, "d")));
}
#[test]
fn handle_event_activate_focused_requests_confirm() {
let mut core = FileDialogCore::new(DialogMode::OpenFile);
set_view_files(&mut core, &["a.txt"]);
core.focused_id = Some(entry_id(&core, "a.txt"));
let outcome = core.handle_event(CoreEvent::ActivateFocused);
assert_eq!(outcome, CoreEventOutcome::RequestConfirm);
assert_eq!(selected_entry_names(&core), vec!["a.txt"]);
}
#[test]
fn handle_event_double_click_file_requests_confirm() {
let mut core = FileDialogCore::new(DialogMode::OpenFile);
set_view_files(&mut core, &["a.txt"]);
let outcome = core.handle_event(CoreEvent::DoubleClickEntry {
id: entry_id(&core, "a.txt"),
});
assert_eq!(outcome, CoreEventOutcome::RequestConfirm);
assert_eq!(selected_entry_names(&core), vec!["a.txt"]);
}
#[test]
fn handle_event_navigate_up_updates_cwd() {
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp/child");
let outcome = core.handle_event(CoreEvent::NavigateUp);
assert_eq!(outcome, CoreEventOutcome::None);
assert_eq!(core.cwd, PathBuf::from("/tmp"));
}
#[test]
fn handle_event_replace_selection_by_ids_uses_id_order() {
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.allow_multi = true;
set_view_files(&mut core, &["a.txt", "b.txt", "c.txt"]);
let c = entry_id(&core, "c.txt");
let a = entry_id(&core, "a.txt");
let outcome = core.handle_event(CoreEvent::ReplaceSelectionByIds(vec![c, a]));
assert_eq!(outcome, CoreEventOutcome::None);
assert_eq!(core.selected_entry_ids(), vec![c, a]);
assert_eq!(selected_entry_names(&core), vec!["c.txt", "a.txt"]);
}
#[test]
fn activate_focused_confirms_selection() {
let mut core = FileDialogCore::new(DialogMode::OpenFile);
set_view_files(&mut core, &["a.txt"]);
core.focused_id = Some(entry_id(&core, "a.txt"));
let gate = ConfirmGate::default();
assert!(core.activate_focused());
core.confirm(&StdFileSystem, &gate, None).unwrap();
let sel = core.take_result().unwrap().unwrap();
assert_eq!(sel.paths.len(), 1);
assert_eq!(
sel.paths[0].file_name().and_then(|s| s.to_str()),
Some("a.txt")
);
}
#[test]
fn open_footer_typed_file_confirms_when_no_selection() {
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
let mut fs = TestFs::default();
fs.meta.insert(
PathBuf::from("/tmp/a.txt"),
crate::fs::FsMetadata {
is_dir: false,
is_symlink: false,
},
);
let gate = ConfirmGate::default();
core.confirm(&fs, &gate, Some("a.txt")).unwrap();
let sel = core.take_result().unwrap().unwrap();
assert_eq!(sel.paths, vec![PathBuf::from("/tmp/a.txt")]);
}
#[test]
fn open_footer_typed_directory_navigates_instead_of_confirming() {
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
let mut fs = TestFs::default();
fs.meta.insert(
PathBuf::from("/tmp/folder"),
crate::fs::FsMetadata {
is_dir: true,
is_symlink: false,
},
);
let gate = ConfirmGate::default();
core.confirm(&fs, &gate, Some("folder")).unwrap();
assert_eq!(core.cwd, PathBuf::from("/tmp/folder"));
assert!(core.take_result().is_none());
}
#[test]
fn pick_folder_confirms_selected_directory_when_present() {
let mut core = FileDialogCore::new(DialogMode::PickFolder);
core.cwd = PathBuf::from("/tmp");
core.entries = vec![make_dir_entry("a"), make_dir_entry("b")];
core.view_names = core
.entries
.iter()
.map(|entry| entry.name.clone())
.collect();
core.view_ids = core.entries.iter().map(|entry| entry.id).collect();
let b = entry_id(&core, "b");
let _ = core.handle_event(CoreEvent::ClickEntry {
id: b,
modifiers: Modifiers::default(),
});
let gate = ConfirmGate::default();
let fs = TestFs::default();
core.confirm(&fs, &gate, None).unwrap();
let sel = core.take_result().unwrap().unwrap();
assert_eq!(sel.paths, vec![PathBuf::from("/tmp/b")]);
}
#[test]
fn save_adds_extension_from_active_filter_when_missing() {
let mut core = FileDialogCore::new(DialogMode::SaveFile);
core.cwd = PathBuf::from("/tmp");
core.save_name = "asset".into();
core.set_filters(vec![FileFilter::new("Images", vec!["png".to_string()])]);
core.save_policy.extension_policy = ExtensionPolicy::AddIfMissing;
core.save_policy.confirm_overwrite = false;
let gate = ConfirmGate::default();
let fs = TestFs::default();
core.confirm(&fs, &gate, None).unwrap();
let sel = core.take_result().unwrap().unwrap();
assert_eq!(sel.paths[0], PathBuf::from("/tmp/asset.png"));
}
#[test]
fn save_keep_user_extension_does_not_modify_name() {
let mut core = FileDialogCore::new(DialogMode::SaveFile);
core.cwd = PathBuf::from("/tmp");
core.save_name = "asset.jpg".into();
core.set_filters(vec![FileFilter::new("Images", vec!["png".to_string()])]);
core.save_policy.extension_policy = ExtensionPolicy::KeepUser;
core.save_policy.confirm_overwrite = false;
let gate = ConfirmGate::default();
let fs = TestFs::default();
core.confirm(&fs, &gate, None).unwrap();
let sel = core.take_result().unwrap().unwrap();
assert_eq!(sel.paths[0], PathBuf::from("/tmp/asset.jpg"));
}
#[test]
fn save_replace_by_filter_replaces_existing_extension() {
let mut core = FileDialogCore::new(DialogMode::SaveFile);
core.cwd = PathBuf::from("/tmp");
core.save_name = "asset.jpg".into();
core.set_filters(vec![FileFilter::new("Images", vec!["png".to_string()])]);
core.save_policy.extension_policy = ExtensionPolicy::ReplaceByFilter;
core.save_policy.confirm_overwrite = false;
let gate = ConfirmGate::default();
let fs = TestFs::default();
core.confirm(&fs, &gate, None).unwrap();
let sel = core.take_result().unwrap().unwrap();
assert_eq!(sel.paths[0], PathBuf::from("/tmp/asset.png"));
}
#[test]
fn matches_filters_supports_multi_layer_extensions() {
let filters = vec![FileFilter::new("VS", vec!["vcxproj.filters".to_string()])];
assert!(matches_filters("proj.vcxproj.filters", &filters));
assert!(!matches_filters("proj.vcxproj", &filters));
assert!(!matches_filters("vcxproj.filters", &filters));
}
#[test]
fn matches_filters_supports_extension_globs() {
let filters = vec![FileFilter::new(
"VS-ish",
vec![".vcx*".to_string(), ".*.filters".to_string()],
)];
assert!(matches_filters("proj.vcxproj.filters", &filters));
assert!(matches_filters("proj.vcxproj", &filters));
assert!(!matches_filters("README", &filters));
}
#[test]
fn matches_filters_supports_regex_tokens() {
let filters = vec![FileFilter::new(
"Re",
vec![r"((^imgui_.*\.rs$))".to_string()],
)];
assert!(matches_filters("imgui_demo.rs", &filters));
assert!(matches_filters("ImGui_DEMO.RS", &filters));
assert!(!matches_filters("demo_imgui.rs", &filters));
}
#[test]
fn natural_sort_orders_digit_runs() {
let mut entries = vec![
make_file_entry("file10.txt"),
make_file_entry("file2.txt"),
make_file_entry("file1.txt"),
];
sort_entries_in_place(
&mut entries,
SortBy::Name,
true,
SortMode::Natural,
false,
1,
);
let names: Vec<_> = entries.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["file1.txt", "file2.txt", "file10.txt"]);
}
#[test]
fn lexicographic_sort_orders_digit_runs_as_strings() {
let mut entries = vec![
make_file_entry("file10.txt"),
make_file_entry("file2.txt"),
make_file_entry("file1.txt"),
];
sort_entries_in_place(
&mut entries,
SortBy::Name,
true,
SortMode::Lexicographic,
false,
1,
);
let names: Vec<_> = entries.into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["file1.txt", "file10.txt", "file2.txt"]);
}
#[test]
fn sort_by_extension_orders_by_full_extension_then_name() {
let mut entries = vec![
make_file_entry("alpha.tar.gz"),
make_file_entry("beta.rs"),
make_file_entry("gamma.tar.gz"),
make_file_entry("noext"),
];
sort_entries_in_place(
&mut entries,
SortBy::Extension,
true,
SortMode::Natural,
false,
1,
);
let names: Vec<_> = entries.into_iter().map(|e| e.name).collect();
assert_eq!(
names,
vec!["noext", "beta.rs", "alpha.tar.gz", "gamma.tar.gz"]
);
}
#[test]
fn select_by_prefix_cycles_from_current_focus() {
let mut core = FileDialogCore::new(DialogMode::OpenFile);
set_view_files(&mut core, &["alpha", "beta", "alpine"]);
core.focused_id = Some(entry_id(&core, "alpha"));
core.select_by_prefix("al");
assert_eq!(selected_entry_names(&core), vec!["alpine"]);
assert_eq!(core.focused_entry_id(), Some(entry_id(&core, "alpine")));
core.select_by_prefix("al");
assert_eq!(selected_entry_names(&core), vec!["alpha"]);
assert_eq!(core.focused_entry_id(), Some(entry_id(&core, "alpha")));
}
#[test]
fn save_prompts_overwrite_when_target_exists_and_policy_enabled() {
let mut core = FileDialogCore::new(DialogMode::SaveFile);
core.cwd = PathBuf::from("/tmp");
core.save_name = "asset.png".into();
core.save_policy.confirm_overwrite = true;
let mut fs = TestFs::default();
fs.meta.insert(
PathBuf::from("/tmp/asset.png"),
crate::fs::FsMetadata {
is_dir: false,
is_symlink: false,
},
);
let gate = ConfirmGate::default();
core.confirm(&fs, &gate, None).unwrap();
assert!(core.take_result().is_none());
assert!(core.pending_overwrite().is_some());
core.accept_overwrite();
assert!(core.pending_overwrite().is_none());
let sel = core.take_result().unwrap().unwrap();
assert_eq!(sel.paths[0], PathBuf::from("/tmp/asset.png"));
}
#[test]
fn scan_hook_can_drop_entries_before_snapshot() {
let fs = TestFs {
entries: vec![
crate::fs::FsEntry {
name: "keep.txt".into(),
path: PathBuf::from("/tmp/keep.txt"),
is_dir: false,
is_symlink: false,
size: Some(1),
modified: None,
},
crate::fs::FsEntry {
name: "drop.txt".into(),
path: PathBuf::from("/tmp/drop.txt"),
is_dir: false,
is_symlink: false,
size: Some(2),
modified: None,
},
],
..Default::default()
};
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.cwd = PathBuf::from("/tmp");
core.set_scan_hook(|entry| {
if entry.name == "drop.txt" {
ScanHookAction::Drop
} else {
ScanHookAction::Keep
}
});
core.rescan_if_needed(&fs);
let names: Vec<&str> = core
.entries()
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert_eq!(names, vec!["keep.txt"]);
assert_eq!(core.dir_snapshot.entry_count, 1);
}
#[test]
fn scan_hook_can_mutate_entry_metadata() {
let fs = TestFs {
entries: vec![crate::fs::FsEntry {
name: "a.txt".into(),
path: PathBuf::from("/tmp/a.txt"),
is_dir: false,
is_symlink: false,
size: Some(12),
modified: Some(std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(7)),
}],
..Default::default()
};
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
core.set_scan_hook(|entry| {
entry.name = "renamed.log".to_string();
entry.path = PathBuf::from("/tmp/renamed.log");
entry.size = Some(99);
entry.modified = None;
ScanHookAction::Keep
});
core.rescan_if_needed(&fs);
let entry = core
.entries()
.iter()
.find(|entry| entry.name == "renamed.log")
.expect("mutated entry should exist");
assert_eq!(entry.path, PathBuf::from("/tmp/renamed.log"));
assert_eq!(entry.size, Some(99));
assert_eq!(entry.modified, None);
}
#[test]
fn scan_hook_invalid_mutation_is_skipped_safely() {
let fs = TestFs {
entries: vec![crate::fs::FsEntry {
name: "a.txt".into(),
path: PathBuf::from("/tmp/a.txt"),
is_dir: false,
is_symlink: false,
size: Some(12),
modified: None,
}],
..Default::default()
};
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
core.set_scan_hook(|entry| {
entry.name.clear();
entry.path = PathBuf::new();
ScanHookAction::Keep
});
core.rescan_if_needed(&fs);
assert!(core.entries().is_empty());
assert_eq!(core.dir_snapshot.entry_count, 0);
}
#[test]
fn clear_scan_hook_restores_raw_listing() {
let fs = TestFs {
entries: vec![crate::fs::FsEntry {
name: "a.txt".into(),
path: PathBuf::from("/tmp/a.txt"),
is_dir: false,
is_symlink: false,
size: Some(1),
modified: None,
}],
..Default::default()
};
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
core.set_scan_hook(|_| ScanHookAction::Drop);
core.rescan_if_needed(&fs);
assert!(core.entries().is_empty());
core.clear_scan_hook();
core.rescan_if_needed(&fs);
assert_eq!(core.entries().len(), 1);
assert_eq!(core.entries()[0].name, "a.txt");
}
#[test]
fn scan_policy_normalizes_and_invalidates_cache() {
let fs = TestFs::default();
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
core.rescan_if_needed(&fs);
assert_eq!(core.scan_generation(), 1);
assert!(!core.dir_snapshot_dirty);
core.set_scan_policy(ScanPolicy::Incremental {
batch_entries: 0,
max_batches_per_tick: 0,
});
assert_eq!(
core.scan_policy(),
ScanPolicy::Incremental {
batch_entries: 1,
max_batches_per_tick: 1
}
);
assert!(core.dir_snapshot_dirty);
core.rescan_if_needed(&fs);
assert_eq!(core.scan_generation(), 2);
}
#[test]
fn scan_policy_tuned_preset_matches_expected_values() {
assert_eq!(
ScanPolicy::tuned_incremental(),
ScanPolicy::Incremental {
batch_entries: ScanPolicy::TUNED_BATCH_ENTRIES,
max_batches_per_tick: ScanPolicy::TUNED_MAX_BATCHES_PER_TICK,
}
);
}
#[test]
fn incremental_scan_policy_applies_multiple_batches_per_tick() {
let fs = TestFs {
entries: vec![
crate::fs::FsEntry {
name: "a.txt".into(),
path: PathBuf::from("/tmp/a.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: "b.txt".into(),
path: PathBuf::from("/tmp/b.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: "c.txt".into(),
path: PathBuf::from("/tmp/c.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: "d.txt".into(),
path: PathBuf::from("/tmp/d.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
],
..Default::default()
};
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
core.set_scan_policy(ScanPolicy::Incremental {
batch_entries: 1,
max_batches_per_tick: 2,
});
core.rescan_if_needed(&fs);
assert_eq!(
core.scan_status(),
&ScanStatus::Partial {
generation: 1,
loaded: 1,
}
);
assert_eq!(core.entries().len(), 1);
core.rescan_if_needed(&fs);
assert_eq!(
core.scan_status(),
&ScanStatus::Partial {
generation: 1,
loaded: 3,
}
);
assert_eq!(core.entries().len(), 3);
core.rescan_if_needed(&fs);
assert_eq!(
core.scan_status(),
&ScanStatus::Complete {
generation: 1,
loaded: 4,
}
);
assert_eq!(core.entries().len(), 4);
}
#[test]
fn rescan_updates_generation_and_status() {
let fs = TestFs {
entries: vec![
crate::fs::FsEntry {
name: "a.txt".into(),
path: PathBuf::from("/tmp/a.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: "b.txt".into(),
path: PathBuf::from("/tmp/b.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
],
..Default::default()
};
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
core.rescan_if_needed(&fs);
assert_eq!(core.scan_generation(), 1);
assert_eq!(
core.scan_status(),
&ScanStatus::Complete {
generation: 1,
loaded: 2,
}
);
}
#[test]
fn incremental_scan_policy_emits_partial_batches_across_ticks() {
let fs = TestFs {
entries: vec![
crate::fs::FsEntry {
name: "a.txt".into(),
path: PathBuf::from("/tmp/a.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: "b.txt".into(),
path: PathBuf::from("/tmp/b.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: "c.txt".into(),
path: PathBuf::from("/tmp/c.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
],
..Default::default()
};
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
core.set_scan_policy(ScanPolicy::Incremental {
batch_entries: 2,
max_batches_per_tick: 1,
});
core.rescan_if_needed(&fs);
assert_eq!(core.scan_generation(), 1);
assert_eq!(core.scan_status(), &ScanStatus::Scanning { generation: 1 });
assert!(core.entries().is_empty());
core.rescan_if_needed(&fs);
assert_eq!(
core.scan_status(),
&ScanStatus::Partial {
generation: 1,
loaded: 2,
}
);
assert_eq!(core.entries().len(), 2);
core.rescan_if_needed(&fs);
assert_eq!(
core.scan_status(),
&ScanStatus::Partial {
generation: 1,
loaded: 3,
}
);
assert_eq!(core.entries().len(), 3);
core.rescan_if_needed(&fs);
assert_eq!(
core.scan_status(),
&ScanStatus::Complete {
generation: 1,
loaded: 3,
}
);
assert_eq!(core.entries().len(), 3);
assert_eq!(fs.read_dir_calls.get(), 1);
}
#[test]
fn incremental_scan_supersedes_pending_generation_batches() {
let fs_old = TestFs {
entries: vec![
crate::fs::FsEntry {
name: "old-a.txt".into(),
path: PathBuf::from("/tmp/old-a.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: "old-b.txt".into(),
path: PathBuf::from("/tmp/old-b.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
],
..Default::default()
};
let fs_new = TestFs {
entries: vec![crate::fs::FsEntry {
name: "new.txt".into(),
path: PathBuf::from("/tmp/new.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
}],
..Default::default()
};
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
core.set_scan_policy(ScanPolicy::Incremental {
batch_entries: 1,
max_batches_per_tick: 1,
});
core.rescan_if_needed(&fs_old);
core.rescan_if_needed(&fs_old);
assert_eq!(core.scan_generation(), 1);
assert_eq!(
core.scan_status(),
&ScanStatus::Partial {
generation: 1,
loaded: 1,
}
);
core.request_rescan();
core.rescan_if_needed(&fs_new);
assert_eq!(core.scan_generation(), 2);
assert_eq!(core.scan_status(), &ScanStatus::Scanning { generation: 2 });
core.rescan_if_needed(&fs_new);
core.rescan_if_needed(&fs_new);
assert_eq!(
core.scan_status(),
&ScanStatus::Complete {
generation: 2,
loaded: 1,
}
);
let names: Vec<&str> = core
.entries()
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert_eq!(names, vec!["new.txt"]);
assert_eq!(fs_old.read_dir_calls.get(), 1);
assert_eq!(fs_new.read_dir_calls.get(), 1);
}
#[test]
fn incremental_scan_keeps_unresolved_selection_until_entry_arrives() {
let fs = TestFs {
entries: vec![
crate::fs::FsEntry {
name: "a.txt".into(),
path: PathBuf::from("/tmp/a.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: "b.txt".into(),
path: PathBuf::from("/tmp/b.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: "c.txt".into(),
path: PathBuf::from("/tmp/c.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
],
..Default::default()
};
let delayed = entry_id_from_path(Path::new("/tmp/c.txt"), false, false);
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.cwd = PathBuf::from("/tmp");
core.set_scan_policy(ScanPolicy::Incremental {
batch_entries: 1,
max_batches_per_tick: 1,
});
core.focus_and_select_by_id(delayed);
core.rescan_if_needed(&fs);
core.rescan_if_needed(&fs);
assert_eq!(core.selected_entry_ids(), vec![delayed]);
assert!(core.selected_entry_paths().is_empty());
core.rescan_if_needed(&fs);
assert_eq!(core.selected_entry_ids(), vec![delayed]);
assert!(core.selected_entry_paths().is_empty());
core.rescan_if_needed(&fs);
assert_eq!(core.selected_entry_ids(), vec![delayed]);
assert_eq!(
core.selected_entry_paths(),
vec![PathBuf::from("/tmp/c.txt")]
);
core.rescan_if_needed(&fs);
assert_eq!(
core.scan_status(),
&ScanStatus::Complete {
generation: 1,
loaded: 3,
}
);
assert_eq!(core.selected_entry_ids(), vec![delayed]);
}
#[test]
fn complete_scan_drops_missing_selection_ids() {
let fs = TestFs {
entries: vec![crate::fs::FsEntry {
name: "a.txt".into(),
path: PathBuf::from("/tmp/a.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
}],
..Default::default()
};
let missing = entry_id_from_path(Path::new("/tmp/missing.txt"), false, false);
let mut core = FileDialogCore::new(DialogMode::OpenFiles);
core.cwd = PathBuf::from("/tmp");
core.set_scan_policy(ScanPolicy::Incremental {
batch_entries: 1,
max_batches_per_tick: 1,
});
core.focus_and_select_by_id(missing);
core.rescan_if_needed(&fs);
core.rescan_if_needed(&fs);
assert_eq!(core.selected_entry_ids(), vec![missing]);
core.rescan_if_needed(&fs);
assert_eq!(
core.scan_status(),
&ScanStatus::Complete {
generation: 1,
loaded: 1,
}
);
assert!(core.selected_entry_ids().is_empty());
assert_eq!(core.focused_entry_id(), None);
}
#[test]
#[ignore = "perf-baseline"]
fn perf_baseline_large_directory_scan_profiles() {
for &entry_count in &[10_000usize, 50_000usize] {
let entries = make_synthetic_fs_entries(entry_count);
let fs_sync = TestFs {
entries: entries.clone(),
..Default::default()
};
let mut core_sync = FileDialogCore::new(DialogMode::OpenFile);
core_sync.cwd = PathBuf::from("/tmp");
let sync_started_at = Instant::now();
core_sync.rescan_if_needed(&fs_sync);
let sync_elapsed = sync_started_at.elapsed();
assert_eq!(
core_sync.scan_status(),
&ScanStatus::Complete {
generation: 1,
loaded: entry_count,
}
);
assert_eq!(core_sync.entries().len(), entry_count);
let fs_incremental = TestFs {
entries,
..Default::default()
};
let mut core_incremental = FileDialogCore::new(DialogMode::OpenFile);
core_incremental.cwd = PathBuf::from("/tmp");
core_incremental.set_scan_policy(ScanPolicy::Incremental {
batch_entries: 512,
max_batches_per_tick: 1,
});
let incremental_started_at = Instant::now();
let mut ticks = 0usize;
loop {
core_incremental.rescan_if_needed(&fs_incremental);
ticks += 1;
match core_incremental.scan_status() {
ScanStatus::Complete { loaded, .. } => {
assert_eq!(*loaded, entry_count);
break;
}
ScanStatus::Failed { message, .. } => {
panic!("incremental perf baseline failed: {message}");
}
_ => {}
}
assert!(
ticks <= (entry_count / 128) + 128,
"incremental ticks exceeded bound: entry_count={entry_count}, ticks={ticks}"
);
}
let incremental_elapsed = incremental_started_at.elapsed();
assert_eq!(core_incremental.entries().len(), entry_count);
eprintln!(
"PERF_BASELINE entry_count={} sync_ms={} incremental_ms={} incremental_ticks={} batch_entries=512",
entry_count,
sync_elapsed.as_millis(),
incremental_elapsed.as_millis(),
ticks,
);
}
}
#[test]
#[ignore = "perf-baseline"]
fn perf_baseline_incremental_budget_sweep() {
let entry_count = 50_000usize;
let base_entries = make_synthetic_fs_entries(entry_count);
for &max_batches_per_tick in &[1usize, 2usize, 4usize] {
let fs_incremental = TestFs {
entries: base_entries.clone(),
..Default::default()
};
let mut core_incremental = FileDialogCore::new(DialogMode::OpenFile);
core_incremental.cwd = PathBuf::from("/tmp");
core_incremental.set_scan_policy(ScanPolicy::Incremental {
batch_entries: 512,
max_batches_per_tick,
});
let incremental_started_at = Instant::now();
let mut ticks = 0usize;
loop {
core_incremental.rescan_if_needed(&fs_incremental);
ticks += 1;
match core_incremental.scan_status() {
ScanStatus::Complete { loaded, .. } => {
assert_eq!(*loaded, entry_count);
break;
}
ScanStatus::Failed { message, .. } => {
panic!("incremental budget sweep failed: {message}");
}
_ => {}
}
assert!(
ticks <= (entry_count / 128) + 128,
"incremental ticks exceeded bound: entry_count={entry_count}, ticks={ticks}"
);
}
let incremental_elapsed = incremental_started_at.elapsed();
assert_eq!(core_incremental.entries().len(), entry_count);
eprintln!(
"PERF_SWEEP entry_count={} incremental_ms={} incremental_ticks={} batch_entries=512 max_batches_per_tick={}",
entry_count,
incremental_elapsed.as_millis(),
ticks,
max_batches_per_tick,
);
}
}
#[test]
fn stale_scan_batch_is_ignored() {
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.scan_generation = 3;
core.scan_status = ScanStatus::Scanning { generation: 3 };
core.apply_scan_batch(ScanBatch::error(2, "stale".to_string()));
assert_eq!(core.scan_status, ScanStatus::Scanning { generation: 3 });
}
#[test]
fn read_dir_failure_sets_failed_scan_status() {
let fs = TestFs {
read_dir_error: Some(std::io::ErrorKind::PermissionDenied),
..Default::default()
};
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
core.rescan_if_needed(&fs);
assert_eq!(core.scan_generation(), 1);
match core.scan_status() {
ScanStatus::Failed {
generation,
message,
} => {
assert_eq!(*generation, 1);
assert!(message.contains("read_dir failure"));
}
other => panic!("unexpected scan status: {other:?}"),
}
assert!(core.entries().is_empty());
assert_eq!(core.dir_snapshot.cwd, PathBuf::from("/tmp"));
assert_eq!(core.dir_snapshot.entry_count, 0);
}
#[test]
fn rescan_if_needed_caches_directory_listing() {
let fs = TestFs {
entries: vec![
crate::fs::FsEntry {
name: "a.txt".into(),
path: PathBuf::from("/tmp/a.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: "b.txt".into(),
path: PathBuf::from("/tmp/b.txt"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
crate::fs::FsEntry {
name: ".hidden".into(),
path: PathBuf::from("/tmp/.hidden"),
is_dir: false,
is_symlink: false,
size: None,
modified: None,
},
],
..Default::default()
};
let mut core = FileDialogCore::new(DialogMode::OpenFile);
core.cwd = PathBuf::from("/tmp");
core.rescan_if_needed(&fs);
assert_eq!(fs.read_dir_calls.get(), 1);
assert!(core.entries().iter().all(|e| e.name != ".hidden"));
core.rescan_if_needed(&fs);
assert_eq!(fs.read_dir_calls.get(), 1);
core.search = "b".into();
core.rescan_if_needed(&fs);
assert_eq!(fs.read_dir_calls.get(), 1);
assert_eq!(core.entries().len(), 1);
assert_eq!(core.entries()[0].name, "b.txt");
core.search.clear();
core.show_hidden = true;
core.rescan_if_needed(&fs);
assert_eq!(fs.read_dir_calls.get(), 1);
assert!(core.entries().iter().any(|e| e.name == ".hidden"));
core.invalidate_dir_cache();
core.rescan_if_needed(&fs);
assert_eq!(fs.read_dir_calls.get(), 2);
core.set_cwd(PathBuf::from("/other"));
core.rescan_if_needed(&fs);
assert_eq!(fs.read_dir_calls.get(), 3);
}
}