Skip to main content

nightshade/
filesystem.rs

1use std::cell::{Cell, RefCell};
2use std::collections::HashMap;
3
4pub struct FileFilter {
5    pub name: String,
6    pub extensions: Vec<String>,
7}
8
9pub enum FileError {
10    NotFound(String),
11    ReadError(String),
12    WriteError(String),
13}
14
15impl std::fmt::Display for FileError {
16    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            FileError::NotFound(path) => write!(formatter, "File not found: {}", path),
19            FileError::ReadError(message) => write!(formatter, "Read error: {}", message),
20            FileError::WriteError(message) => write!(formatter, "Write error: {}", message),
21        }
22    }
23}
24
25pub struct LoadedFile {
26    pub name: String,
27    pub bytes: Vec<u8>,
28}
29
30thread_local! {
31    static COMPLETED_LOADS: RefCell<HashMap<u64, LoadedFile>> = RefCell::new(HashMap::new());
32    static NEXT_LOAD_ID: Cell<u64> = const { Cell::new(1) };
33}
34
35fn allocate_load_id() -> u64 {
36    NEXT_LOAD_ID.with(|cell| {
37        let id = cell.get();
38        cell.set(id + 1);
39        id
40    })
41}
42
43fn store_completed_load(id: u64, file: LoadedFile) {
44    COMPLETED_LOADS.with(|cell| {
45        cell.borrow_mut().insert(id, file);
46    });
47}
48
49#[derive(Clone, Copy)]
50pub struct PendingFileLoad {
51    id: u64,
52}
53
54impl PendingFileLoad {
55    pub fn empty() -> Self {
56        Self {
57            id: allocate_load_id(),
58        }
59    }
60
61    pub fn ready(file: LoadedFile) -> Self {
62        let id = allocate_load_id();
63        store_completed_load(id, file);
64        Self { id }
65    }
66
67    pub fn is_ready(&self) -> bool {
68        COMPLETED_LOADS.with(|cell| cell.borrow().contains_key(&self.id))
69    }
70
71    pub fn take(&self) -> Option<LoadedFile> {
72        COMPLETED_LOADS.with(|cell| cell.borrow_mut().remove(&self.id))
73    }
74}
75
76#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
77pub fn pick_file(filters: &[FileFilter]) -> Option<std::path::PathBuf> {
78    let mut dialog = rfd::FileDialog::new();
79    for filter in filters {
80        let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
81        dialog = dialog.add_filter(&filter.name, &extensions);
82    }
83    dialog.pick_file()
84}
85
86#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
87pub fn pick_folder() -> Option<std::path::PathBuf> {
88    rfd::FileDialog::new().pick_folder()
89}
90
91#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
92pub fn save_file_dialog(
93    filters: &[FileFilter],
94    default_filename: Option<&str>,
95) -> Option<std::path::PathBuf> {
96    let mut dialog = rfd::FileDialog::new();
97    if let Some(filename) = default_filename {
98        dialog = dialog.set_file_name(filename);
99    }
100    for filter in filters {
101        let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
102        dialog = dialog.add_filter(&filter.name, &extensions);
103    }
104    dialog.save_file()
105}
106
107#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
108pub fn read_file(path: &std::path::Path) -> Result<Vec<u8>, FileError> {
109    if !path.exists() {
110        return Err(FileError::NotFound(path.display().to_string()));
111    }
112    std::fs::read(path).map_err(|error| FileError::ReadError(error.to_string()))
113}
114
115#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
116pub fn write_file(path: &std::path::Path, bytes: &[u8]) -> Result<(), FileError> {
117    if let Some(parent) = path.parent() {
118        std::fs::create_dir_all(parent)
119            .map_err(|error| FileError::WriteError(error.to_string()))?;
120    }
121    std::fs::write(path, bytes).map_err(|error| FileError::WriteError(error.to_string()))
122}
123
124#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
125pub fn save_file(filename: &str, data: &[u8], filters: &[FileFilter]) -> Result<(), FileError> {
126    let mut dialog = rfd::FileDialog::new().set_file_name(filename);
127    for filter in filters {
128        let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
129        dialog = dialog.add_filter(&filter.name, &extensions);
130    }
131    match dialog.save_file() {
132        Some(path) => write_file(&path, data),
133        None => Ok(()),
134    }
135}
136
137#[cfg(all(not(target_arch = "wasm32"), feature = "file_dialog"))]
138pub fn request_file_load(filters: &[FileFilter]) -> PendingFileLoad {
139    let mut dialog = rfd::FileDialog::new();
140    for filter in filters {
141        let extensions: Vec<&str> = filter.extensions.iter().map(|s| s.as_str()).collect();
142        dialog = dialog.add_filter(&filter.name, &extensions);
143    }
144    match dialog.pick_file() {
145        Some(path) => {
146            let name = path
147                .file_name()
148                .map(|os_name| os_name.to_string_lossy().to_string())
149                .unwrap_or_default();
150            match std::fs::read(&path) {
151                Ok(bytes) => PendingFileLoad::ready(LoadedFile { name, bytes }),
152                Err(_) => PendingFileLoad::empty(),
153            }
154        }
155        None => PendingFileLoad::empty(),
156    }
157}
158
159#[cfg(not(target_arch = "wasm32"))]
160pub fn open_directory(path: &std::path::Path) {
161    let directory = if path.is_dir() {
162        path
163    } else {
164        path.parent().unwrap_or(path)
165    };
166
167    #[cfg(target_os = "windows")]
168    {
169        let _ = std::process::Command::new("explorer")
170            .arg(directory)
171            .spawn();
172    }
173
174    #[cfg(target_os = "macos")]
175    {
176        let _ = std::process::Command::new("open").arg(directory).spawn();
177    }
178
179    #[cfg(target_os = "linux")]
180    {
181        let _ = std::process::Command::new("xdg-open")
182            .arg(directory)
183            .spawn();
184    }
185}
186
187#[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
188pub fn save_file(filename: &str, data: &[u8], _filters: &[FileFilter]) -> Result<(), FileError> {
189    use wasm_bindgen::JsCast;
190
191    let window =
192        web_sys::window().ok_or_else(|| FileError::WriteError("No window object".to_string()))?;
193    let document = window
194        .document()
195        .ok_or_else(|| FileError::WriteError("No document object".to_string()))?;
196
197    let uint8_array = js_sys::Uint8Array::from(data);
198    let parts = js_sys::Array::new();
199    parts.push(&uint8_array);
200
201    let options = web_sys::BlobPropertyBag::new();
202    options.set_type("application/octet-stream");
203
204    let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options)
205        .map_err(|_| FileError::WriteError("Failed to create Blob".to_string()))?;
206
207    let url = web_sys::Url::create_object_url_with_blob(&blob)
208        .map_err(|_| FileError::WriteError("Failed to create object URL".to_string()))?;
209
210    let anchor: web_sys::HtmlAnchorElement = document
211        .create_element("a")
212        .map_err(|_| FileError::WriteError("Failed to create anchor element".to_string()))?
213        .dyn_into()
214        .map_err(|_| FileError::WriteError("Failed to cast to anchor".to_string()))?;
215
216    anchor.set_href(&url);
217    anchor.set_download(filename);
218    anchor.click();
219
220    let _ = web_sys::Url::revoke_object_url(&url);
221    Ok(())
222}
223
224#[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
225pub fn request_file_load(filters: &[FileFilter]) -> PendingFileLoad {
226    use wasm_bindgen::JsCast;
227    use wasm_bindgen::prelude::*;
228
229    let pending = PendingFileLoad::empty();
230    let pending_id = pending.id;
231
232    let Some(window) = web_sys::window() else {
233        return pending;
234    };
235    let Some(document) = window.document() else {
236        return pending;
237    };
238    let Some(input) = document
239        .create_element("input")
240        .ok()
241        .and_then(|element| element.dyn_into::<web_sys::HtmlInputElement>().ok())
242    else {
243        return pending;
244    };
245
246    input.set_type("file");
247
248    let accept: String = filters
249        .iter()
250        .flat_map(|filter| {
251            filter
252                .extensions
253                .iter()
254                .map(|extension| format!(".{}", extension))
255        })
256        .collect::<Vec<_>>()
257        .join(",");
258    if !accept.is_empty() {
259        input.set_accept(&accept);
260    }
261
262    let input_for_closure = input.clone();
263    let closure = Closure::once(Box::new(move |_event: web_sys::Event| {
264        let captured_input = input_for_closure;
265
266        wasm_bindgen_futures::spawn_local(async move {
267            if let Some(files) = captured_input.files()
268                && let Some(file) = files.get(0)
269            {
270                let name = file.name();
271                let array_buffer_promise = file.array_buffer();
272                if let Ok(array_buffer) =
273                    wasm_bindgen_futures::JsFuture::from(array_buffer_promise).await
274                {
275                    let uint8_array = js_sys::Uint8Array::new(&array_buffer);
276                    let bytes = uint8_array.to_vec();
277                    store_completed_load(pending_id, LoadedFile { name, bytes });
278                }
279            }
280        });
281    }) as Box<dyn FnOnce(_)>);
282
283    input.set_onchange(Some(closure.as_ref().unchecked_ref()));
284    closure.forget();
285    input.click();
286
287    pending
288}