Skip to main content

ferroui_dialogs/internal/
managed_file_chooser_view_model.rs

1use super::{
2    BclMountedVolumeInfoProvider, FerroDialogsInternalViewModelBase, ManagedFileChooserFilterViewModel,
3    ManagedFileChooserItemType, ManagedFileChooserItemViewModel, ManagedFileChooserSources,
4};
5use crate::ManagedFileDialogOptions;
6use ferroui_base::animation::TimeSpan;
7use ferroui_base::collections::FerroList;
8use ferroui_base::data::model::{Event, INotifyPropertyChanged};
9use ferroui_base::metadata::from_markup_value;
10use ferroui_base::platform::storage::file_io::path;
11use ferroui_base::platform::storage::{FilePickerOpenOptions, FilePickerSaveOptions, FolderPickerOpenOptions, IStorageFolder, IStorageItem};
12use ferroui_base::reactive::{CompositeDisposable, Disposable, IDisposable};
13use ferroui_base::threading::{Dispatcher, DispatcherPriority};
14use ferroui_base::utilities::{DateTime, DateTimeOffset, HandlerList, StringComparison};
15use ferroui_base::{ferro_markup_type, BoxedValue, FerroLocator, LocatorExtensions};
16use ferroui_controls::platform::IMountedVolumeInfoProvider;
17use ferroui_controls::primitives::SelectedItemsList;
18use ferroui_controls::SelectionMode;
19use std::cell::{Cell, RefCell};
20use std::rc::{Rc, Weak};
21
22/// The state of the managed file chooser: the folder it shows, its
23/// entries, the selection, the filters and the quick links.
24pub struct ManagedFileChooserViewModel {
25    base: FerroDialogsInternalViewModelBase,
26    options: ManagedFileDialogOptions,
27    cancel_requested: Rc<HandlerList<dyn Fn()>>,
28    complete_requested: Rc<HandlerList<dyn Fn(&[String])>>,
29    overwrite_prompt: Rc<HandlerList<dyn Fn(&str)>>,
30
31    quick_links: FerroList<Rc<ManagedFileChooserItemViewModel>>,
32    items: FerroList<Rc<ManagedFileChooserItemViewModel>>,
33    filters: FerroList<Rc<ManagedFileChooserFilterViewModel>>,
34    selected_items: SelectedItemsList,
35
36    location: RefCell<Option<String>>,
37    file_name: RefCell<Option<String>>,
38    show_hidden_files: Cell<bool>,
39    selected_filter: RefCell<Option<Rc<ManagedFileChooserFilterViewModel>>>,
40    selecting_directory: Cell<bool>,
41    saving_file: Cell<bool>,
42    scheduled_selection_validation: Cell<bool>,
43    already_cancelled: Cell<bool>,
44    default_extension: RefCell<Option<String>>,
45    overwrite_prompt_enabled: Cell<bool>,
46    disposables: CompositeDisposable,
47
48    show_filters: Cell<bool>,
49    selection_mode: Cell<SelectionMode>,
50    title: RefCell<Option<String>>,
51}
52
53impl PartialEq for ManagedFileChooserViewModel {
54    fn eq(&self, other: &Self) -> bool {
55        std::ptr::eq(self, other)
56    }
57}
58
59impl INotifyPropertyChanged for ManagedFileChooserViewModel {
60    fn property_changed(&self) -> &Event<str> {
61        self.base.property_changed()
62    }
63}
64
65/// The view model of an entry of the selection (the selection holds the
66/// untyped items of the list box).
67fn item_of(value: &Option<BoxedValue>) -> Option<Rc<ManagedFileChooserItemViewModel>> {
68    from_markup_value::<Rc<ManagedFileChooserItemViewModel>>(value)
69}
70
71fn boxed(item: &Rc<ManagedFileChooserItemViewModel>) -> Option<BoxedValue> {
72    Some(item.clone() as BoxedValue)
73}
74
75fn is_null_or_white_space(value: Option<&str>) -> bool {
76    value.is_none_or(|value| value.trim().is_empty())
77}
78
79/// `Path.Combine(path1, path2)`: `path2` when it is rooted, else the two
80/// joined with a separator.
81fn combine(path1: &str, path2: &str) -> String {
82    if path1.is_empty() || std::path::Path::new(path2).has_root() || path2.starts_with('/') {
83        return path2.to_string();
84    }
85    if path1.ends_with(path::is_directory_separator) {
86        format!("{path1}{path2}")
87    } else {
88        format!("{path1}{}{path2}", std::path::MAIN_SEPARATOR)
89    }
90}
91
92/// The current directory of the process (`Directory.GetCurrentDirectory()`).
93fn get_current_directory() -> String {
94    std::env::current_dir().map(|path| path.to_string_lossy().into_owned()).unwrap_or_default()
95}
96
97fn directory_exists(path: Option<&str>) -> bool {
98    path.is_some_and(|path| !path.is_empty() && std::path::Path::new(path).is_dir())
99}
100
101fn file_exists(path: Option<&str>) -> bool {
102    path.is_some_and(|path| !path.is_empty() && std::path::Path::new(path).is_file())
103}
104
105/// The local time of the last write of an entry (`LastWriteTime`).
106fn last_write_time(metadata: &std::fs::Metadata) -> DateTime {
107    const NANOSECONDS_PER_TICK: u128 = 100;
108
109    let Ok(time) = metadata.modified() else {
110        return DateTime::default();
111    };
112    let ticks = match time.duration_since(std::time::SystemTime::UNIX_EPOCH) {
113        Ok(after) => (after.as_nanos() / NANOSECONDS_PER_TICK) as i64,
114        Err(before) => -((before.duration().as_nanos() / NANOSECONDS_PER_TICK) as i64),
115    };
116    DateTimeOffset::UNIX_EPOCH.add(TimeSpan::from_ticks(ticks)).local_date_time()
117}
118
119/// Whether an entry is hidden from the listing when hidden files are not
120/// shown.
121#[cfg(windows)]
122fn is_hidden(_name: &str, metadata: &std::fs::Metadata) -> bool {
123    use std::os::windows::fs::MetadataExt;
124
125    const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
126    const FILE_ATTRIBUTE_SYSTEM: u32 = 0x4;
127    metadata.file_attributes() & (FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM) != 0
128}
129
130#[cfg(not(windows))]
131fn is_hidden(name: &str, _metadata: &std::fs::Metadata) -> bool {
132    name.starts_with('.')
133}
134
135/// An entry of a directory: its name, full path, kind and metadata.
136struct FileSystemEntry {
137    name: String,
138    full_name: String,
139    is_directory: bool,
140    metadata: std::fs::Metadata,
141}
142
143/// The entries of a directory (`DirectoryInfo.EnumerateFileSystemInfos()`).
144/// A link is an entry of the kind of its target; a link without target is a
145/// file. An entry whose metadata cannot be read (it no longer exists) is
146/// left out (`Exists`); a failure to read the directory itself is reported,
147/// as the enumeration of the original throws.
148fn enumerate_file_system_infos(directory: &str) -> std::io::Result<Vec<FileSystemEntry>> {
149    let mut entries = Vec::new();
150    for entry in std::fs::read_dir(directory)? {
151        let entry = entry?;
152        let full_path = entry.path();
153        let Ok(metadata) = std::fs::metadata(&full_path).or_else(|_| std::fs::symlink_metadata(&full_path)) else {
154            continue;
155        };
156        entries.push(FileSystemEntry {
157            name: entry.file_name().to_string_lossy().into_owned(),
158            full_name: full_path.to_string_lossy().into_owned(),
159            is_directory: metadata.is_dir(),
160            metadata,
161        });
162    }
163    Ok(entries)
164}
165
166impl ManagedFileChooserViewModel {
167    /// The constructor all the others start with.
168    pub fn new(options: ManagedFileDialogOptions) -> Rc<Self> {
169        let this = Rc::new(Self {
170            base: FerroDialogsInternalViewModelBase::new(),
171            options,
172            cancel_requested: Rc::new(HandlerList::new()),
173            complete_requested: Rc::new(HandlerList::new()),
174            overwrite_prompt: Rc::new(HandlerList::new()),
175            quick_links: FerroList::new(),
176            items: FerroList::new(),
177            filters: FerroList::new(),
178            selected_items: SelectedItemsList::new(),
179            location: RefCell::new(None),
180            file_name: RefCell::new(None),
181            show_hidden_files: Cell::new(false),
182            selected_filter: RefCell::new(None),
183            selecting_directory: Cell::new(false),
184            saving_file: Cell::new(false),
185            scheduled_selection_validation: Cell::new(false),
186            already_cancelled: Cell::new(false),
187            default_extension: RefCell::new(None),
188            overwrite_prompt_enabled: Cell::new(false),
189            disposables: CompositeDisposable::new(),
190            show_filters: Cell::new(false),
191            selection_mode: Cell::new(SelectionMode::SINGLE),
192            title: RefCell::new(None),
193        });
194
195        let quick_sources =
196            FerroLocator::current().get_service::<ManagedFileChooserSources>().unwrap_or_else(|| Rc::new(ManagedFileChooserSources::new()));
197
198        let sub1 = FerroLocator::current()
199            .get_service::<dyn IMountedVolumeInfoProvider>()
200            .unwrap_or_else(|| Rc::new(BclMountedVolumeInfoProvider))
201            .listen(ManagedFileChooserSources::mounted_volumes());
202
203        // Holds the view model weakly, as the weak collection change
204        // subscription of the original.
205        let sub2 = {
206            let mounted_volumes = ManagedFileChooserSources::mounted_volumes();
207            let weak = Rc::downgrade(&this);
208            let quick_sources = quick_sources.clone();
209            let token = mounted_volumes.add_collection_changed(Rc::new(move |_| {
210                let weak = weak.clone();
211                let quick_sources = quick_sources.clone();
212                Dispatcher::ui_thread().post_local(
213                    move || {
214                        if let Some(this) = weak.upgrade() {
215                            this.refresh_quick_links(&quick_sources);
216                        }
217                    },
218                    DispatcherPriority::DEFAULT,
219                );
220            }));
221            Disposable::create(move || {
222                mounted_volumes.remove_collection_changed(token);
223            })
224        };
225
226        this.disposables.add(sub1);
227        this.disposables.add(sub2);
228
229        let weak = Rc::downgrade(&this);
230        this.complete_requested.add(Rc::new(move |_: &[String]| {
231            if let Some(this) = weak.upgrade() {
232                this.disposables.dispose();
233            }
234        }));
235        let weak = Rc::downgrade(&this);
236        this.cancel_requested.add(Rc::new(move || {
237            if let Some(this) = weak.upgrade() {
238                this.disposables.dispose();
239            }
240        }));
241
242        this.refresh_quick_links(&quick_sources);
243
244        let weak = Rc::downgrade(&this);
245        this.selected_items.add_collection_changed(Rc::new(move |_| {
246            if let Some(this) = weak.upgrade() {
247                this.on_selection_changed_async();
248            }
249        }));
250
251        this
252    }
253
254    /// The chooser of an open file picker.
255    pub fn new_open_file(file_picker_open: &FilePickerOpenOptions, options: ManagedFileDialogOptions) -> Rc<Self> {
256        let this = Self::new(options);
257        *this.title.borrow_mut() = Some(file_picker_open.title().unwrap_or("Open file").to_string());
258
259        if let Some(file_type_filter) = file_picker_open.file_type_filter().filter(|filter| !filter.is_empty()) {
260            this.filters.add_range(
261                file_type_filter
262                    .iter()
263                    .enumerate()
264                    .map(|(i, f)| ManagedFileChooserFilterViewModel::new_with_index(f, i as i32)),
265            );
266            *this.selected_filter.borrow_mut() = Some(this.filters.get(0));
267            this.show_filters.set(true);
268        }
269
270        if file_picker_open.allow_multiple() {
271            this.selection_mode.set(SelectionMode::MULTIPLE);
272        }
273
274        this.navigate_folder(file_picker_open.suggested_start_location(), None);
275        this
276    }
277
278    /// The chooser of a save file picker.
279    pub fn new_save_file(file_picker_save: &FilePickerSaveOptions, options: ManagedFileDialogOptions) -> Rc<Self> {
280        let this = Self::new(options);
281        *this.title.borrow_mut() = Some(file_picker_save.title().unwrap_or("Save file").to_string());
282
283        if let Some(file_type_choices) = file_picker_save.file_type_choices().filter(|choices| !choices.is_empty()) {
284            this.filters.add_range(
285                file_type_choices
286                    .iter()
287                    .enumerate()
288                    .map(|(i, f)| ManagedFileChooserFilterViewModel::new_with_index(f, i as i32)),
289            );
290            *this.selected_filter.borrow_mut() = Some(this.filters.get(0));
291            this.show_filters.set(true);
292        }
293
294        this.saving_file.set(true);
295        *this.default_extension.borrow_mut() = file_picker_save.default_extension().map(str::to_string);
296        this.overwrite_prompt_enabled.set(file_picker_save.show_overwrite_prompt().unwrap_or(true));
297        this.set_file_name(file_picker_save.suggested_file_name().map(str::to_string));
298
299        let file_name = this.file_name();
300        this.navigate_folder(file_picker_save.suggested_start_location(), file_name.as_deref());
301        this
302    }
303
304    /// The chooser of a folder picker.
305    pub fn new_open_folder(folder_picker_open: &FolderPickerOpenOptions, options: ManagedFileDialogOptions) -> Rc<Self> {
306        let this = Self::new(options);
307        *this.title.borrow_mut() = Some(folder_picker_open.title().unwrap_or("Select directory").to_string());
308
309        this.selecting_directory.set(true);
310
311        if folder_picker_open.allow_multiple() {
312            this.selection_mode.set(SelectionMode::MULTIPLE);
313        }
314
315        this.navigate_folder(folder_picker_open.suggested_start_location(), None);
316        this
317    }
318
319    // --- events ---
320
321    /// Raised when the chooser is canceled.
322    pub fn cancel_requested(&self, handler: impl Fn() + 'static) -> Rc<dyn IDisposable> {
323        let token = self.cancel_requested.add(Rc::new(handler));
324        let handlers = self.cancel_requested.clone();
325        Disposable::create(move || {
326            handlers.remove(token);
327        })
328    }
329
330    /// Raised with the chosen paths when the chooser completes.
331    pub fn complete_requested(&self, handler: impl Fn(&[String]) + 'static) -> Rc<dyn IDisposable> {
332        let token = self.complete_requested.add(Rc::new(handler));
333        let handlers = self.complete_requested.clone();
334        Disposable::create(move || {
335            handlers.remove(token);
336        })
337    }
338
339    /// Raised with the full path of an existing file the user chose to
340    /// save to, when overwriting needs a confirmation.
341    pub fn overwrite_prompt(&self, handler: impl Fn(&str) + 'static) -> Rc<dyn IDisposable> {
342        let token = self.overwrite_prompt.add(Rc::new(handler));
343        let handlers = self.overwrite_prompt.clone();
344        Disposable::create(move || {
345            handlers.remove(token);
346        })
347    }
348
349    fn raise_cancel_requested(&self) {
350        for (_, handler) in self.cancel_requested.snapshot().iter() {
351            handler();
352        }
353    }
354
355    fn raise_complete_requested(&self, items: &[String]) {
356        for (_, handler) in self.complete_requested.snapshot().iter() {
357            handler(items);
358        }
359    }
360
361    fn raise_overwrite_prompt(&self, file_name: &str) {
362        for (_, handler) in self.overwrite_prompt.snapshot().iter() {
363            handler(file_name);
364        }
365    }
366
367    // --- properties ---
368
369    pub fn quick_links(&self) -> FerroList<Rc<ManagedFileChooserItemViewModel>> {
370        self.quick_links.clone()
371    }
372
373    pub fn items(&self) -> FerroList<Rc<ManagedFileChooserItemViewModel>> {
374        self.items.clone()
375    }
376
377    pub fn filters(&self) -> FerroList<Rc<ManagedFileChooserFilterViewModel>> {
378        self.filters.clone()
379    }
380
381    /// The selected entries, as the untyped list the list box of the
382    /// chooser fills: each item is a [`ManagedFileChooserItemViewModel`].
383    pub fn selected_items(&self) -> SelectedItemsList {
384        self.selected_items.clone()
385    }
386
387    /// The selected entries as view models.
388    pub fn selected_item_view_models(&self) -> Vec<Rc<ManagedFileChooserItemViewModel>> {
389        self.selected_items.to_vec().iter().filter_map(item_of).collect()
390    }
391
392    pub fn location(&self) -> Option<String> {
393        self.location.borrow().clone()
394    }
395
396    pub fn set_location(&self, value: Option<String>) {
397        self.base.raise_and_set_if_changed(&self.location, value, "Location");
398    }
399
400    pub fn file_name(&self) -> Option<String> {
401        self.file_name.borrow().clone()
402    }
403
404    pub fn set_file_name(&self, value: Option<String>) {
405        self.base.raise_and_set_if_changed(&self.file_name, value, "FileName");
406    }
407
408    pub fn selecting_folder(&self) -> bool {
409        self.selecting_directory.get()
410    }
411
412    pub fn show_filters(&self) -> bool {
413        self.show_filters.get()
414    }
415
416    pub fn selection_mode(&self) -> SelectionMode {
417        self.selection_mode.get()
418    }
419
420    pub fn title(&self) -> Option<String> {
421        self.title.borrow().clone()
422    }
423
424    /// The index of the quick link of the current location, or -1.
425    pub fn quick_links_selected_index(&self) -> i32 {
426        let location = self.location();
427        for (index, i) in self.quick_links.snapshot().iter().enumerate() {
428            if i.path() == location {
429                return index as i32;
430            }
431        }
432
433        -1
434    }
435
436    /// Setting the index only notifies a change: the index follows the
437    /// location.
438    pub fn set_quick_links_selected_index(&self, _value: i32) {
439        self.base.raise_property_changed("QuickLinksSelectedIndex");
440    }
441
442    pub fn selected_filter(&self) -> Option<Rc<ManagedFileChooserFilterViewModel>> {
443        self.selected_filter.borrow().clone()
444    }
445
446    pub fn set_selected_filter(&self, value: Option<Rc<ManagedFileChooserFilterViewModel>>) {
447        self.base.raise_and_set_if_changed(&self.selected_filter, value, "SelectedFilter");
448        self.refresh();
449    }
450
451    pub fn show_hidden_files(&self) -> bool {
452        self.show_hidden_files.get()
453    }
454
455    pub fn set_show_hidden_files(&self, value: bool) {
456        self.base.raise_and_set_if_changed_cell(&self.show_hidden_files, value, "ShowHiddenFiles");
457        self.refresh();
458    }
459
460    fn refresh_quick_links(&self, quick_sources: &ManagedFileChooserSources) {
461        self.quick_links.clear();
462        self.quick_links.add_range(
463            quick_sources.get_all_items().iter().map(ManagedFileChooserItemViewModel::from_navigation_item),
464        );
465    }
466
467    // --- operations ---
468
469    pub fn enter_pressed(&self) {
470        let location = self.location();
471        if directory_exists(location.as_deref()) {
472            self.navigate(location.as_deref(), None);
473        } else if file_exists(location.as_deref()) {
474            self.raise_complete_requested(&[location.unwrap_or_default()]);
475        }
476    }
477
478    /// Validates the selection once the list box has finished changing it,
479    /// from a dispatcher job: folders are removed when they cannot be
480    /// chosen, and the file name shows the selected entries.
481    fn on_selection_changed_async(self: &Rc<Self>) {
482        if self.scheduled_selection_validation.get() {
483            return;
484        }
485
486        self.scheduled_selection_validation.set(true);
487        let weak: Weak<Self> = Rc::downgrade(self);
488        Dispatcher::ui_thread().post_local(
489            move || {
490                let Some(this) = weak.upgrade() else { return };
491
492                struct Reset<'a>(&'a Cell<bool>);
493                impl Drop for Reset<'_> {
494                    fn drop(&mut self) {
495                        self.0.set(false);
496                    }
497                }
498                let _reset = Reset(&this.scheduled_selection_validation);
499
500                if this.selecting_directory.get() {
501                    this.selected_items.clear();
502                } else {
503                    if !this.options.allow_directory_selection() {
504                        let invalid_items: Vec<Option<BoxedValue>> = this
505                            .selected_items
506                            .to_vec()
507                            .into_iter()
508                            .filter(|i| item_of(i).is_some_and(|i| i.item_type() == ManagedFileChooserItemType::Folder))
509                            .collect();
510                        for item in invalid_items {
511                            this.selected_items.remove(&item);
512                        }
513                    }
514
515                    if !this.selecting_directory.get() {
516                        let selected = this.selected_item_view_models();
517                        if selected.len() > 1 {
518                            let mut sb = String::new();
519
520                            for (i, item) in selected.iter().enumerate() {
521                                sb.push('"');
522                                sb.push_str(item.display_name().as_deref().unwrap_or_default());
523                                sb.push('"');
524                                if i + 1 < selected.len() {
525                                    sb.push(' ');
526                                }
527                            }
528
529                            this.set_file_name(Some(sb));
530                        } else {
531                            this.set_file_name(selected.first().and_then(|item| item.display_name()));
532                        }
533                    }
534                }
535                this.base.raise_property_changed("SelectedItems");
536            },
537            DispatcherPriority::DEFAULT,
538        );
539    }
540
541    fn navigate_root(&self, initial_selection_name: Option<&str>) -> std::io::Result<()> {
542        if cfg!(windows) {
543            // The root of the system folder.
544            let system = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string());
545            let root = std::path::Path::new(&system)
546                .ancestors()
547                .last()
548                .map(|root| root.to_string_lossy().into_owned())
549                .unwrap_or(system);
550            self.try_navigate(Some(&root), initial_selection_name)
551        } else {
552            self.try_navigate(Some("/"), initial_selection_name)
553        }
554    }
555
556    pub fn refresh(&self) {
557        let location = self.location();
558        self.navigate(location.as_deref(), None);
559    }
560
561    /// Navigates to a folder, or to the current directory without one.
562    pub fn navigate_folder(&self, path: Option<&Rc<dyn IStorageFolder>>, initial_selection_name: Option<&str>) {
563        let full_directory_path = path
564            .and_then(|path| {
565                let item: &dyn IStorageItem = &**path;
566                item.try_get_local_path()
567            })
568            .unwrap_or_else(get_current_directory);
569
570        self.navigate(Some(&full_directory_path), initial_selection_name);
571    }
572
573    /// Navigates to the directory `path`, or to the root of the file system
574    /// when it does not exist, and selects the file named
575    /// `initial_selection_name` there.
576    ///
577    /// # Panics
578    /// Panics when the directory cannot be read for another reason than a
579    /// denied access (the exception `Navigate` lets through in the
580    /// original); see [`try_navigate`](Self::try_navigate).
581    pub fn navigate(&self, path: Option<&str>, initial_selection_name: Option<&str>) {
582        if let Err(error) = self.try_navigate(path, initial_selection_name) {
583            panic!("{}: {error}", path.unwrap_or_default());
584        }
585    }
586
587    /// [`navigate`](Self::navigate), reporting the failure to read the
588    /// directory. A denied access is not a failure: the directory is shown
589    /// empty, as the original catches the access exception.
590    pub fn try_navigate(&self, path: Option<&str>, initial_selection_name: Option<&str>) -> std::io::Result<()> {
591        if !directory_exists(path) {
592            return self.navigate_root(initial_selection_name);
593        } else {
594            let path = path.unwrap_or_default();
595            self.set_location(Some(path.to_string()));
596            self.items.clear();
597            self.selected_items.clear();
598
599            let mut infos = match enumerate_file_system_infos(path) {
600                Ok(infos) => infos,
601                Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok(()),
602                Err(error) => return Err(error),
603            };
604
605            if !self.show_hidden_files() {
606                infos.retain(|i| !is_hidden(&i.name, &i.metadata));
607            }
608
609            if let Some(selected_filter) = self.selected_filter() {
610                infos.retain(|i| i.is_directory || selected_filter.is_match(&i.name));
611            }
612
613            let selecting_directory = self.selecting_directory.get();
614            let mut items: Vec<Rc<ManagedFileChooserItemViewModel>> = infos
615                .into_iter()
616                .filter(|x| !selecting_directory || x.is_directory)
617                .map(|info| {
618                    let item = ManagedFileChooserItemViewModel::new();
619                    item.set_display_name(Some(info.name.clone()));
620                    item.set_path(Some(info.full_name.clone()));
621                    item.set_type(Some(if info.is_directory {
622                        "File Folder".to_string()
623                    } else {
624                        path::get_extension(&info.name).to_string()
625                    }));
626                    item.set_item_type(if info.is_directory {
627                        ManagedFileChooserItemType::Folder
628                    } else {
629                        ManagedFileChooserItemType::File
630                    });
631                    item.set_size(if info.is_directory { 0 } else { info.metadata.len() as i64 });
632                    item.set_modified(last_write_time(&info.metadata));
633                    item
634                })
635                .collect();
636            // Folders first, then by name; the sort is stable.
637            items.sort_by(|a, b| {
638                let a_folder = a.item_type() == ManagedFileChooserItemType::Folder;
639                let b_folder = b.item_type() == ManagedFileChooserItemType::Folder;
640                b_folder.cmp(&a_folder).then_with(|| {
641                    StringComparison::InvariantCultureIgnoreCase.compare(
642                        a.display_name().as_deref().unwrap_or_default(),
643                        b.display_name().as_deref().unwrap_or_default(),
644                    )
645                })
646            });
647            self.items.add_range(items);
648
649            if let Some(initial_selection_name) = initial_selection_name {
650                let sel = self.items.snapshot().iter().find(|i| {
651                    i.item_type() == ManagedFileChooserItemType::File
652                        && i.display_name().as_deref() == Some(initial_selection_name)
653                }).cloned();
654
655                if let Some(sel) = sel {
656                    self.selected_items.add(boxed(&sel));
657                }
658            }
659
660            self.base.raise_property_changed("QuickLinksSelectedIndex");
661        }
662
663        Ok(())
664    }
665
666    pub fn go_up(&self) {
667        let location = self.location();
668        let parent = location.as_deref().and_then(path::get_directory_name);
669
670        if is_null_or_white_space(parent) {
671            return;
672        }
673
674        self.navigate(parent, None);
675    }
676
677    pub fn cancel(&self) {
678        if !self.already_cancelled.get() {
679            // INFO: Don't misplace this check or it might cause
680            //       a stack overflow because of recursive
681            //       event invokes.
682            self.already_cancelled.set(true);
683            self.raise_cancel_requested();
684        }
685    }
686
687    /// Whether [`ok`](Self::ok) can complete the chooser.
688    pub fn can_ok(&self, _parameter: Option<BoxedValue>) -> bool {
689        (self.selecting_directory.get() && !is_null_or_white_space(self.location().as_deref()))
690            || (self.saving_file.get() && !is_null_or_white_space(self.file_name().as_deref()))
691            || (!self.selected_items.is_empty()
692                && self.selected_item_view_models().iter().any(|s| !is_null_or_white_space(s.path().as_deref())))
693    }
694
695    pub fn ok(&self) {
696        if self.selecting_directory.get() {
697            self.raise_complete_requested(&[self.location().unwrap_or_default()]);
698        } else if self.saving_file.get() {
699            if let Some(mut file_name) = self.file_name().filter(|name| !name.trim().is_empty()) {
700                let default_extension = self.default_extension.borrow().clone();
701                if !path::has_extension(&file_name) && !is_null_or_white_space(default_extension.as_deref()) {
702                    file_name = path::change_extension(&file_name, default_extension.as_deref());
703                    self.set_file_name(Some(file_name.clone()));
704                }
705
706                let full_name = combine(&self.location().unwrap_or_default(), &file_name);
707
708                if self.overwrite_prompt_enabled.get() && file_exists(Some(&full_name)) {
709                    self.raise_overwrite_prompt(&full_name);
710                } else {
711                    self.raise_complete_requested(&[full_name]);
712                }
713            }
714        } else {
715            let paths: Vec<String> =
716                self.selected_item_view_models().iter().map(|i| i.path().unwrap_or_default()).collect();
717            self.raise_complete_requested(&paths);
718        }
719    }
720
721    pub fn select_single_file(&self, item: &ManagedFileChooserItemViewModel) {
722        self.raise_complete_requested(&[item.path().unwrap_or_default()]);
723    }
724}
725
726ferro_markup_type!(class ManagedFileChooserViewModel {
727    this: Rc<ManagedFileChooserViewModel>,
728    handles: [ManagedFileChooserViewModel, Rc<ManagedFileChooserViewModel>, Option<Rc<ManagedFileChooserViewModel>>],
729    properties: [
730        QuickLinks: FerroList<Rc<ManagedFileChooserItemViewModel>> { get: ManagedFileChooserViewModel::quick_links },
731        Items: FerroList<Rc<ManagedFileChooserItemViewModel>> { get: ManagedFileChooserViewModel::items },
732        Filters: FerroList<Rc<ManagedFileChooserFilterViewModel>> { get: ManagedFileChooserViewModel::filters },
733        SelectedItems: SelectedItemsList { get: ManagedFileChooserViewModel::selected_items },
734        Location: Option<String> { get: ManagedFileChooserViewModel::location, set: ManagedFileChooserViewModel::set_location },
735        FileName: Option<String> { get: ManagedFileChooserViewModel::file_name, set: ManagedFileChooserViewModel::set_file_name },
736        SelectingFolder: bool { get: ManagedFileChooserViewModel::selecting_folder },
737        ShowFilters: bool { get: ManagedFileChooserViewModel::show_filters },
738        SelectionMode: SelectionMode { get: ManagedFileChooserViewModel::selection_mode },
739        Title: Option<String> { get: ManagedFileChooserViewModel::title },
740        QuickLinksSelectedIndex: i32 {
741            get: ManagedFileChooserViewModel::quick_links_selected_index,
742            set: ManagedFileChooserViewModel::set_quick_links_selected_index
743        },
744        SelectedFilter: Option<Rc<ManagedFileChooserFilterViewModel>> {
745            get: ManagedFileChooserViewModel::selected_filter,
746            set: ManagedFileChooserViewModel::set_selected_filter
747        },
748        ShowHiddenFiles: bool {
749            get: ManagedFileChooserViewModel::show_hidden_files,
750            set: ManagedFileChooserViewModel::set_show_hidden_files
751        },
752    ],
753    methods: [
754        fn EnterPressed() => ManagedFileChooserViewModel::enter_pressed,
755        fn Refresh() => ManagedFileChooserViewModel::refresh,
756        fn GoUp() => ManagedFileChooserViewModel::go_up,
757        fn Cancel() => ManagedFileChooserViewModel::cancel,
758        fn CanOk(Option<BoxedValue>) -> bool => ManagedFileChooserViewModel::can_ok
759            [DependsOn("FileName"), DependsOn("Location"), DependsOn("SelectedItems")],
760        fn Ok() => ManagedFileChooserViewModel::ok,
761        fn SelectSingleFile(Rc<ManagedFileChooserItemViewModel>) =>
762            (|this: &Rc<ManagedFileChooserViewModel>, item: Rc<ManagedFileChooserItemViewModel>| this.select_single_file(&item)),
763    ],
764    notify_property_changed: ManagedFileChooserViewModel,
765});