Skip to main content

ferroui_dialogs/internal/
managed_file_chooser_sources.rs

1use super::{ManagedFileChooserItemType, ManagedFileChooserNavigationItem};
2use ferroui_base::collections::FerroList;
3use ferroui_base::platform::storage::file_io::path;
4use ferroui_base::utilities::ByteSizeHelper;
5use ferroui_controls::platform::MountedVolumeInfo;
6use std::cell::RefCell;
7use std::path::{Path, PathBuf};
8use std::rc::Rc;
9
10/// The places the managed file chooser offers as quick links: the folders
11/// of the user and the mounted volumes. Each source can be replaced; an
12/// instance registered with the locator is used instead of the default one.
13///
14/// Internal upstream; public here for the platform backends.
15pub struct ManagedFileChooserSources {
16    get_user_directories: RefCell<Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>>>,
17    get_file_system_roots: RefCell<Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>>>,
18    get_all_items_delegate: RefCell<Rc<dyn Fn(&ManagedFileChooserSources) -> Vec<ManagedFileChooserNavigationItem>>>,
19}
20
21ferroui_base::ferro_thread_local! {
22    static MOUNTED_VOLUMES: FerroList<MountedVolumeInfo> = FerroList::new();
23}
24
25/// The special folders of the user the quick links start with, in order
26/// (`Environment.SpecialFolder`).
27#[derive(Clone, Copy)]
28enum SpecialFolder {
29    Desktop,
30    UserProfile,
31    MyDocuments,
32    MyMusic,
33    MyPictures,
34    MyVideos,
35}
36
37const FOLDERS: [SpecialFolder; 6] = [
38    SpecialFolder::Desktop,
39    SpecialFolder::UserProfile,
40    SpecialFolder::MyDocuments,
41    SpecialFolder::MyMusic,
42    SpecialFolder::MyPictures,
43    SpecialFolder::MyVideos,
44];
45
46fn home_directory() -> Option<PathBuf> {
47    let variable = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
48    std::env::var_os(variable).filter(|home| !home.is_empty()).map(PathBuf::from)
49}
50
51/// The directory `key` names for the user: the environment variable of
52/// that name, else the entry of the user directories of the desktop
53/// (`user-dirs.dirs`), else `fallback` in the home directory.
54fn read_xdg_directory(home: &Path, key: &str, fallback: &str) -> PathBuf {
55    if let Some(value) = std::env::var_os(key).filter(|value| !value.is_empty()) {
56        return PathBuf::from(value);
57    }
58
59    let config_home = std::env::var_os("XDG_CONFIG_HOME")
60        .filter(|config_home| !config_home.is_empty())
61        .map(PathBuf::from)
62        .unwrap_or_else(|| home.join(".config"));
63    if let Ok(content) = std::fs::read_to_string(config_home.join("user-dirs.dirs")) {
64        if let Some(directory) = parse_xdg_user_directory(&content, home, key) {
65            return directory;
66        }
67    }
68
69    home.join(fallback)
70}
71
72/// Finds `key="value"` in the content of a `user-dirs.dirs` file. The value
73/// is an absolute path or a path relative to the home directory
74/// (`$HOME/...`).
75fn parse_xdg_user_directory(content: &str, home: &Path, key: &str) -> Option<PathBuf> {
76    for line in content.lines() {
77        let line = line.trim_start();
78        let Some(rest) = line.strip_prefix(key) else { continue };
79        let Some(rest) = rest.trim_start().strip_prefix('=') else { continue };
80        let Some(rest) = rest.trim_start().strip_prefix('"') else { continue };
81        let Some(end) = rest.find('"') else { continue };
82        let value = &rest[..end];
83
84        if let Some(relative) = value.strip_prefix("$HOME/") {
85            return Some(home.join(relative));
86        }
87        if value.starts_with('/') {
88            return Some(PathBuf::from(value));
89        }
90    }
91    None
92}
93
94/// The path of a special folder of the user (`Environment.GetFolderPath`),
95/// or an empty string when it has none.
96fn get_folder_path(folder: SpecialFolder) -> String {
97    let Some(home) = home_directory() else {
98        return String::new();
99    };
100
101    let path = if cfg!(windows) {
102        match folder {
103            SpecialFolder::Desktop => home.join("Desktop"),
104            SpecialFolder::UserProfile => home,
105            SpecialFolder::MyDocuments => home.join("Documents"),
106            SpecialFolder::MyMusic => home.join("Music"),
107            SpecialFolder::MyPictures => home.join("Pictures"),
108            SpecialFolder::MyVideos => home.join("Videos"),
109        }
110    } else {
111        match folder {
112            SpecialFolder::UserProfile | SpecialFolder::MyDocuments => home,
113            SpecialFolder::Desktop => read_xdg_directory(&home, "XDG_DESKTOP_DIR", "Desktop"),
114            SpecialFolder::MyMusic if cfg!(target_os = "macos") => home.join("Music"),
115            SpecialFolder::MyMusic => read_xdg_directory(&home, "XDG_MUSIC_DIR", "Music"),
116            SpecialFolder::MyPictures if cfg!(target_os = "macos") => home.join("Pictures"),
117            SpecialFolder::MyPictures => read_xdg_directory(&home, "XDG_PICTURES_DIR", "Pictures"),
118            SpecialFolder::MyVideos => read_xdg_directory(&home, "XDG_VIDEOS_DIR", "Videos"),
119        }
120    };
121    path.to_string_lossy().into_owned()
122}
123
124impl Default for ManagedFileChooserSources {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130impl ManagedFileChooserSources {
131    pub fn new() -> Self {
132        Self {
133            get_user_directories: RefCell::new(Rc::new(Self::default_get_user_directories)),
134            get_file_system_roots: RefCell::new(Rc::new(Self::default_get_file_system_roots)),
135            get_all_items_delegate: RefCell::new(Rc::new(Self::default_get_all_items)),
136        }
137    }
138
139    pub fn get_user_directories(&self) -> Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>> {
140        self.get_user_directories.borrow().clone()
141    }
142
143    pub fn set_get_user_directories(&self, value: Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>>) {
144        *self.get_user_directories.borrow_mut() = value;
145    }
146
147    pub fn get_file_system_roots(&self) -> Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>> {
148        self.get_file_system_roots.borrow().clone()
149    }
150
151    pub fn set_get_file_system_roots(&self, value: Rc<dyn Fn() -> Vec<ManagedFileChooserNavigationItem>>) {
152        *self.get_file_system_roots.borrow_mut() = value;
153    }
154
155    pub fn get_all_items_delegate(&self) -> Rc<dyn Fn(&ManagedFileChooserSources) -> Vec<ManagedFileChooserNavigationItem>> {
156        self.get_all_items_delegate.borrow().clone()
157    }
158
159    pub fn set_get_all_items_delegate(
160        &self,
161        value: Rc<dyn Fn(&ManagedFileChooserSources) -> Vec<ManagedFileChooserNavigationItem>>,
162    ) {
163        *self.get_all_items_delegate.borrow_mut() = value;
164    }
165
166    pub fn get_all_items(&self) -> Vec<ManagedFileChooserNavigationItem> {
167        (self.get_all_items_delegate())(self)
168    }
169
170    /// The mounted volumes, filled by the mounted volume info provider (one
171    /// list per thread: the static collection of the original).
172    pub fn mounted_volumes() -> FerroList<MountedVolumeInfo> {
173        MOUNTED_VOLUMES.with(FerroList::clone)
174    }
175
176    pub fn default_get_all_items(sources: &ManagedFileChooserSources) -> Vec<ManagedFileChooserNavigationItem> {
177        let mut items = (sources.get_user_directories())();
178        items.extend((sources.get_file_system_roots())());
179        items
180    }
181
182    pub fn default_get_user_directories() -> Vec<ManagedFileChooserNavigationItem> {
183        let mut paths: Vec<String> = Vec::new();
184        for path in FOLDERS.iter().map(|folder| get_folder_path(*folder)) {
185            if !paths.contains(&path) {
186                paths.push(path);
187            }
188        }
189
190        paths
191            .into_iter()
192            .filter(|d| !d.trim().is_empty())
193            .filter(|d| Path::new(d).is_dir())
194            .map(|d| ManagedFileChooserNavigationItem {
195                item_type: ManagedFileChooserItemType::Folder,
196                display_name: Some(path::get_file_name(&d).to_string()),
197                path: Some(d),
198            })
199            .collect()
200    }
201
202    pub fn default_get_file_system_roots() -> Vec<ManagedFileChooserNavigationItem> {
203        Self::mounted_volumes()
204            .to_vec()
205            .into_iter()
206            .filter_map(|x| {
207                let mut display_name = x.volume_label.clone();
208
209                if display_name.is_none() & (x.volume_size_bytes > 0) {
210                    display_name = Some(format!("{} Volume", ByteSizeHelper::to_string(x.volume_size_bytes, true)));
211                }
212
213                // A volume whose files cannot be listed is left out.
214                let path = x.volume_path.clone();
215                let files = path.as_deref().map(std::fs::read_dir);
216                if !matches!(files, Some(Ok(_))) {
217                    return None;
218                }
219
220                Some(ManagedFileChooserNavigationItem {
221                    item_type: ManagedFileChooserItemType::Volume,
222                    display_name,
223                    path,
224                })
225            })
226            .collect()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    // Not from upstream: the upstream project has no tests.
233    use super::*;
234
235    #[test]
236    fn all_items_are_the_user_directories_followed_by_the_roots() {
237        let sources = ManagedFileChooserSources::new();
238        let item = |name: &str, item_type| ManagedFileChooserNavigationItem {
239            display_name: Some(name.to_string()),
240            path: Some(format!("/{name}")),
241            item_type,
242        };
243        sources.set_get_user_directories(Rc::new(move || vec![item("home", ManagedFileChooserItemType::Folder)]));
244        sources.set_get_file_system_roots(Rc::new(move || vec![item("mnt", ManagedFileChooserItemType::Volume)]));
245
246        let names: Vec<_> = sources.get_all_items().into_iter().map(|i| i.display_name.unwrap()).collect();
247        assert_eq!(vec!["home", "mnt"], names);
248    }
249
250    #[test]
251    fn roots_are_the_listable_mounted_volumes() {
252        let root = std::env::temp_dir();
253        let volumes = ManagedFileChooserSources::mounted_volumes();
254        volumes.clear();
255        volumes.add(MountedVolumeInfo {
256            volume_label: None,
257            volume_path: Some(root.to_string_lossy().into_owned()),
258            volume_size_bytes: 1500,
259        });
260        volumes.add(MountedVolumeInfo {
261            volume_label: Some("Missing".to_string()),
262            volume_path: Some("/this/volume/does/not/exist".to_string()),
263            volume_size_bytes: 0,
264        });
265
266        let roots = ManagedFileChooserSources::default_get_file_system_roots();
267        volumes.clear();
268
269        assert_eq!(1, roots.len());
270        assert_eq!(Some("1.5KB Volume".to_string()), roots[0].display_name);
271        assert_eq!(ManagedFileChooserItemType::Volume, roots[0].item_type);
272    }
273
274    #[test]
275    fn user_directories_exist_and_are_distinct() {
276        let directories = ManagedFileChooserSources::default_get_user_directories();
277        for (index, directory) in directories.iter().enumerate() {
278            let path = directory.path.as_deref().unwrap();
279            assert!(Path::new(path).is_dir());
280            assert_eq!(ManagedFileChooserItemType::Folder, directory.item_type);
281            assert!(directories[index + 1..].iter().all(|other| other.path.as_deref() != Some(path)));
282        }
283    }
284
285    #[test]
286    fn xdg_entries_are_read_relative_to_home_or_absolute() {
287        let home = Path::new("/home/user");
288        let content = "# comment\nXDG_MUSIC_DIR=\"$HOME/Tunes\"\nXDG_VIDEOS_DIR=\"/media/videos\"\n";
289
290        assert_eq!(Some(PathBuf::from("/home/user/Tunes")), parse_xdg_user_directory(content, home, "XDG_MUSIC_DIR"));
291        assert_eq!(Some(PathBuf::from("/media/videos")), parse_xdg_user_directory(content, home, "XDG_VIDEOS_DIR"));
292        assert_eq!(None, parse_xdg_user_directory(content, home, "XDG_DESKTOP_DIR"));
293    }
294}