1use std::{future::Future, pin::Pin, rc::Rc};
17
18use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
19use cranpose_macros::composable;
20
21use crate::content::{ContentError, ContentFolderRef, ContentHandle, ContentSinkRef};
22
23#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
25pub enum FilePickerError {
26 #[error("file picker failed: {0}")]
28 Failed(String),
29 #[error(transparent)]
31 Content(#[from] ContentError),
32 #[error("{operation} requires cranpose-services feature `{feature}`")]
34 UnsupportedFeature {
35 operation: &'static str,
37 feature: &'static str,
39 },
40 #[error("file picking is not available on this platform")]
42 UnsupportedPlatform,
43}
44
45pub type PickerFuture<T> = Pin<Box<dyn Future<Output = T>>>;
47
48#[derive(Clone, Debug, Default, PartialEq, Eq)]
50pub struct FileFilter {
51 pub label: String,
53 pub extensions: Vec<String>,
55 pub mime_types: Vec<String>,
59}
60
61impl FileFilter {
62 pub fn new(label: impl Into<String>, extensions: &[&str]) -> Self {
64 Self {
65 label: label.into(),
66 extensions: extensions.iter().map(|ext| (*ext).to_string()).collect(),
67 mime_types: Vec::new(),
68 }
69 }
70
71 pub fn with_mime_types(mut self, mime_types: &[&str]) -> Self {
73 self.mime_types = mime_types.iter().map(|mime| (*mime).to_string()).collect();
74 self
75 }
76}
77
78#[derive(Clone, Debug, Default, PartialEq, Eq)]
80pub struct FilePickerOptions {
81 pub title: Option<String>,
83 pub filters: Vec<FileFilter>,
85}
86
87impl FilePickerOptions {
88 pub fn with_title(mut self, title: impl Into<String>) -> Self {
90 self.title = Some(title.into());
91 self
92 }
93
94 pub fn with_filter(mut self, filter: FileFilter) -> Self {
96 self.filters.push(filter);
97 self
98 }
99
100 pub fn mime_types(&self) -> Vec<String> {
102 self.filters
103 .iter()
104 .flat_map(|filter| filter.mime_types.iter().cloned())
105 .collect()
106 }
107}
108
109#[derive(Clone, Debug, Default, PartialEq, Eq)]
111pub struct SaveDocumentRequest {
112 pub file_name: String,
114 pub mime_type: String,
117 pub title: Option<String>,
119}
120
121impl SaveDocumentRequest {
122 pub fn new(file_name: impl Into<String>, mime_type: impl Into<String>) -> Self {
124 Self {
125 file_name: file_name.into(),
126 mime_type: mime_type.into(),
127 title: None,
128 }
129 }
130
131 pub fn with_title(mut self, title: impl Into<String>) -> Self {
133 self.title = Some(title.into());
134 self
135 }
136}
137
138#[doc(hidden)]
139pub enum RecoveredPick {
140 File(ContentHandle),
141 Files(Vec<ContentHandle>),
142 Folder(ContentFolderRef),
143 WritableFolder(String),
144}
145
146pub trait FilePicker {
150 fn pick_file(
152 &self,
153 options: FilePickerOptions,
154 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>>;
155
156 fn pick_files(
161 &self,
162 options: FilePickerOptions,
163 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
164 let single = self.pick_file(options);
165 Box::pin(async move { Ok(single.await?.into_iter().collect()) })
166 }
167
168 fn pick_folder(
170 &self,
171 options: FilePickerOptions,
172 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>>;
173
174 fn save_document(
177 &self,
178 request: SaveDocumentRequest,
179 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
180 let _ = request;
181 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
182 }
183
184 fn export_file(
191 &self,
192 path: std::path::PathBuf,
193 request: SaveDocumentRequest,
194 ) -> PickerFuture<Result<bool, FilePickerError>> {
195 #[cfg(not(target_arch = "wasm32"))]
196 {
197 let saving = self.save_document(request);
198 Box::pin(async move {
199 let Some(sink) = saving.await? else {
200 return Ok(false);
201 };
202 let file = crate::content::file_content(path);
203 crate::content::copy_content(&*file, &sink, |_| {}).await?;
204 Ok(true)
205 })
206 }
207 #[cfg(target_arch = "wasm32")]
208 {
209 let _ = (path, request);
210 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
211 }
212 }
213
214 fn pick_writable_folder(
218 &self,
219 options: FilePickerOptions,
220 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
221 let _ = options;
222 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
223 }
224
225 #[doc(hidden)]
226 fn take_recovered_pick(&self) -> Option<RecoveredPick> {
227 None
228 }
229}
230
231pub type FilePickerRef = Rc<dyn FilePicker>;
233
234pub fn set_platform_file_picker(picker: FilePickerRef) {
240 crate::thread_state::with(|state| *state.file_picker.borrow_mut() = Some(picker));
241}
242
243pub fn clear_platform_file_picker() {
245 crate::thread_state::with(|state| *state.file_picker.borrow_mut() = None);
246}
247
248fn registered_platform_file_picker() -> Option<FilePickerRef> {
249 crate::thread_state::with(|state| state.file_picker.borrow().clone())
250}
251
252struct PlatformFilePicker;
253
254impl FilePicker for PlatformFilePicker {
255 fn pick_file(
256 &self,
257 options: FilePickerOptions,
258 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
259 match registered_platform_file_picker() {
260 Some(picker) => picker.pick_file(options),
261 None => builtin::pick_file(options),
262 }
263 }
264
265 fn pick_files(
266 &self,
267 options: FilePickerOptions,
268 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
269 match registered_platform_file_picker() {
270 Some(picker) => picker.pick_files(options),
271 None => builtin::pick_files(options),
272 }
273 }
274
275 fn pick_folder(
276 &self,
277 options: FilePickerOptions,
278 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
279 match registered_platform_file_picker() {
280 Some(picker) => picker.pick_folder(options),
281 None => builtin::pick_folder(options),
282 }
283 }
284
285 fn save_document(
286 &self,
287 request: SaveDocumentRequest,
288 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
289 match registered_platform_file_picker() {
290 Some(picker) => picker.save_document(request),
291 None => builtin::save_document(request),
292 }
293 }
294
295 fn pick_writable_folder(
296 &self,
297 options: FilePickerOptions,
298 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
299 match registered_platform_file_picker() {
300 Some(picker) => picker.pick_writable_folder(options),
301 None => builtin::pick_writable_folder(options),
302 }
303 }
304
305 fn take_recovered_pick(&self) -> Option<RecoveredPick> {
306 registered_platform_file_picker().and_then(|picker| picker.take_recovered_pick())
307 }
308}
309
310pub fn default_file_picker() -> FilePickerRef {
312 Rc::new(PlatformFilePicker)
313}
314
315pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
317 crate::composition_locals::cached_local(
318 |locals| &locals.file_picker,
319 || compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq),
320 )
321}
322
323#[composable]
325pub fn ProvideFilePicker(content: impl FnOnce()) {
326 let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
327 let picker_local = local_file_picker();
328
329 CompositionLocalProvider([picker_local.provides(picker)], move || {
330 content();
331 });
332}
333
334mod builtin {
335
336 #[cfg(all(
337 not(target_arch = "wasm32"),
338 not(target_os = "android"),
339 not(target_os = "ios"),
340 feature = "file-picker-native"
341 ))]
342 pub(super) use super::desktop::{
343 pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
344 };
345 #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
346 pub(super) use super::web::{
347 pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
348 };
349
350 #[cfg(not(any(
351 all(
352 not(target_arch = "wasm32"),
353 not(target_os = "android"),
354 not(target_os = "ios"),
355 feature = "file-picker-native"
356 ),
357 all(target_arch = "wasm32", feature = "file-picker-web")
358 )))]
359 mod unsupported {
360 use crate::{
361 content::{ContentFolderRef, ContentHandle, ContentSinkRef},
362 file_picker::{FilePickerError, FilePickerOptions, PickerFuture, SaveDocumentRequest},
363 };
364
365 pub(in crate::file_picker) fn pick_file(
366 _options: FilePickerOptions,
367 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
368 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
369 }
370
371 pub(in crate::file_picker) fn pick_files(
372 _options: FilePickerOptions,
373 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
374 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
375 }
376
377 pub(in crate::file_picker) fn pick_folder(
378 _options: FilePickerOptions,
379 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
380 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
381 }
382
383 pub(in crate::file_picker) fn save_document(
384 _request: SaveDocumentRequest,
385 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
386 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
387 }
388
389 pub(in crate::file_picker) fn pick_writable_folder(
390 _options: FilePickerOptions,
391 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
392 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
393 }
394 }
395
396 #[cfg(not(any(
397 all(
398 not(target_arch = "wasm32"),
399 not(target_os = "android"),
400 not(target_os = "ios"),
401 feature = "file-picker-native"
402 ),
403 all(target_arch = "wasm32", feature = "file-picker-web")
404 )))]
405 pub(super) use unsupported::{
406 pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
407 };
408}
409
410#[cfg(all(
411 not(target_arch = "wasm32"),
412 not(target_os = "android"),
413 not(target_os = "ios"),
414 feature = "file-picker-native"
415))]
416mod desktop;
417
418#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
419mod web;
420
421#[cfg(test)]
422#[path = "tests/file_picker_tests.rs"]
423mod tests;