1use crate::content::{ContentError, ContentFolderRef, ContentHandle, ContentSinkRef};
17use cranpose_core::compositionLocalOfWithPolicy;
18use cranpose_core::CompositionLocal;
19use cranpose_core::CompositionLocalProvider;
20use cranpose_macros::composable;
21use std::cell::RefCell;
22use std::future::Future;
23use std::pin::Pin;
24use std::rc::Rc;
25
26#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
28pub enum FilePickerError {
29 #[error("file picker failed: {0}")]
31 Failed(String),
32 #[error(transparent)]
34 Content(#[from] ContentError),
35 #[error("{operation} requires cranpose-services feature `{feature}`")]
37 UnsupportedFeature {
38 operation: &'static str,
40 feature: &'static str,
42 },
43 #[error("file picking is not available on this platform")]
45 UnsupportedPlatform,
46}
47
48pub type PickerFuture<T> = Pin<Box<dyn Future<Output = T>>>;
50
51#[derive(Clone, Debug, Default, PartialEq, Eq)]
53pub struct FileFilter {
54 pub label: String,
56 pub extensions: Vec<String>,
58 pub mime_types: Vec<String>,
62}
63
64impl FileFilter {
65 pub fn new(label: impl Into<String>, extensions: &[&str]) -> Self {
67 Self {
68 label: label.into(),
69 extensions: extensions.iter().map(|ext| (*ext).to_string()).collect(),
70 mime_types: Vec::new(),
71 }
72 }
73
74 pub fn with_mime_types(mut self, mime_types: &[&str]) -> Self {
76 self.mime_types = mime_types.iter().map(|mime| (*mime).to_string()).collect();
77 self
78 }
79}
80
81#[derive(Clone, Debug, Default, PartialEq, Eq)]
83pub struct FilePickerOptions {
84 pub title: Option<String>,
86 pub filters: Vec<FileFilter>,
88}
89
90impl FilePickerOptions {
91 pub fn with_title(mut self, title: impl Into<String>) -> Self {
93 self.title = Some(title.into());
94 self
95 }
96
97 pub fn with_filter(mut self, filter: FileFilter) -> Self {
99 self.filters.push(filter);
100 self
101 }
102
103 pub fn mime_types(&self) -> Vec<String> {
105 self.filters
106 .iter()
107 .flat_map(|filter| filter.mime_types.iter().cloned())
108 .collect()
109 }
110}
111
112#[derive(Clone, Debug, Default, PartialEq, Eq)]
114pub struct SaveDocumentRequest {
115 pub file_name: String,
117 pub mime_type: String,
120 pub title: Option<String>,
122}
123
124impl SaveDocumentRequest {
125 pub fn new(file_name: impl Into<String>, mime_type: impl Into<String>) -> Self {
127 Self {
128 file_name: file_name.into(),
129 mime_type: mime_type.into(),
130 title: None,
131 }
132 }
133
134 pub fn with_title(mut self, title: impl Into<String>) -> Self {
136 self.title = Some(title.into());
137 self
138 }
139}
140
141#[doc(hidden)]
149pub enum RecoveredPick {
150 File(ContentHandle),
152 Files(Vec<ContentHandle>),
154 Folder(ContentFolderRef),
156 WritableFolder(String),
158}
159
160pub trait FilePicker {
164 fn pick_file(
166 &self,
167 options: FilePickerOptions,
168 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>>;
169
170 fn pick_files(
175 &self,
176 options: FilePickerOptions,
177 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
178 let single = self.pick_file(options);
179 Box::pin(async move { Ok(single.await?.into_iter().collect()) })
180 }
181
182 fn pick_folder(
184 &self,
185 options: FilePickerOptions,
186 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>>;
187
188 fn save_document(
191 &self,
192 request: SaveDocumentRequest,
193 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
194 let _ = request;
195 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
196 }
197
198 fn pick_writable_folder(
202 &self,
203 options: FilePickerOptions,
204 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
205 let _ = options;
206 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
207 }
208
209 #[doc(hidden)]
213 fn take_recovered_pick(&self) -> Option<RecoveredPick> {
214 None
215 }
216}
217
218pub type FilePickerRef = Rc<dyn FilePicker>;
220
221thread_local! {
222 static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
223}
224
225pub fn set_platform_file_picker(picker: FilePickerRef) {
231 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
232}
233
234pub fn clear_platform_file_picker() {
236 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
237}
238
239fn registered_platform_file_picker() -> Option<FilePickerRef> {
240 PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
241}
242
243struct PlatformFilePicker;
246
247impl FilePicker for PlatformFilePicker {
248 fn pick_file(
249 &self,
250 options: FilePickerOptions,
251 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
252 match registered_platform_file_picker() {
253 Some(picker) => picker.pick_file(options),
254 None => builtin::pick_file(options),
255 }
256 }
257
258 fn pick_files(
259 &self,
260 options: FilePickerOptions,
261 ) -> PickerFuture<Result<Vec<ContentHandle>, FilePickerError>> {
262 match registered_platform_file_picker() {
263 Some(picker) => picker.pick_files(options),
264 None => builtin::pick_files(options),
265 }
266 }
267
268 fn pick_folder(
269 &self,
270 options: FilePickerOptions,
271 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
272 match registered_platform_file_picker() {
273 Some(picker) => picker.pick_folder(options),
274 None => builtin::pick_folder(options),
275 }
276 }
277
278 fn save_document(
279 &self,
280 request: SaveDocumentRequest,
281 ) -> PickerFuture<Result<Option<ContentSinkRef>, FilePickerError>> {
282 match registered_platform_file_picker() {
283 Some(picker) => picker.save_document(request),
284 None => builtin::save_document(request),
285 }
286 }
287
288 fn pick_writable_folder(
289 &self,
290 options: FilePickerOptions,
291 ) -> PickerFuture<Result<Option<String>, FilePickerError>> {
292 match registered_platform_file_picker() {
293 Some(picker) => picker.pick_writable_folder(options),
294 None => builtin::pick_writable_folder(options),
295 }
296 }
297
298 fn take_recovered_pick(&self) -> Option<RecoveredPick> {
299 registered_platform_file_picker().and_then(|picker| picker.take_recovered_pick())
300 }
301}
302
303pub fn default_file_picker() -> FilePickerRef {
305 Rc::new(PlatformFilePicker)
306}
307
308pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
310 thread_local! {
311 static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
312 }
313
314 LOCAL_FILE_PICKER.with(|cell| {
315 cell.borrow_mut()
316 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
317 .clone()
318 })
319}
320
321#[allow(non_snake_case)]
323#[composable]
324pub fn ProvideFilePicker(content: impl FnOnce()) {
325 let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
326 let picker_local = local_file_picker();
327
328 CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
329 content();
330 });
331}
332
333mod builtin {
334
335 #[cfg(all(
336 not(target_arch = "wasm32"),
337 not(target_os = "android"),
338 not(target_os = "ios"),
339 feature = "file-picker-native"
340 ))]
341 pub(super) use super::desktop::{
342 pick_file, pick_files, pick_folder, pick_writable_folder, save_document,
343 };
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::content::{ContentFolderRef, ContentHandle, ContentSinkRef};
361 use crate::file_picker::{
362 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)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn options_builder_sets_title_and_filters() {
427 let options = FilePickerOptions::default()
428 .with_title("Pick audio")
429 .with_filter(FileFilter::new("Audio", &["mp3", "flac"]).with_mime_types(&["audio/*"]));
430 assert_eq!(options.title.as_deref(), Some("Pick audio"));
431 assert_eq!(options.filters.len(), 1);
432 assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
433 assert_eq!(options.mime_types(), vec!["audio/*"]);
434 }
435
436 #[test]
437 fn default_picker_is_created() {
438 let picker = default_file_picker();
439 assert_eq!(Rc::strong_count(&picker), 1);
440 }
441
442 struct Marker;
443
444 impl FilePicker for Marker {
445 fn pick_file(
446 &self,
447 _options: FilePickerOptions,
448 ) -> PickerFuture<Result<Option<ContentHandle>, FilePickerError>> {
449 Box::pin(async {
450 Ok(Some(
451 crate::content::BytesContent::named("marker.txt", b"marker".to_vec()).handle(),
452 ))
453 })
454 }
455
456 fn pick_folder(
457 &self,
458 _options: FilePickerOptions,
459 ) -> PickerFuture<Result<Option<ContentFolderRef>, FilePickerError>> {
460 Box::pin(async { Ok(None) })
461 }
462 }
463
464 #[test]
465 fn registered_platform_picker_takes_precedence() {
466 clear_platform_file_picker();
467 assert!(registered_platform_file_picker().is_none());
468 set_platform_file_picker(Rc::new(Marker));
469 assert!(registered_platform_file_picker().is_some());
470
471 let picked =
472 pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()))
473 .expect("the marker picker resolves")
474 .expect("the marker picker picks a file");
475 assert_eq!(picked.metadata().name, "marker.txt");
476 clear_platform_file_picker();
477 }
478
479 #[test]
480 fn multi_selection_falls_back_to_the_single_chooser() {
481 clear_platform_file_picker();
482 set_platform_file_picker(Rc::new(Marker));
483 let picked =
484 pollster::block_on(default_file_picker().pick_files(FilePickerOptions::default()))
485 .expect("the marker picker resolves");
486 assert_eq!(picked.len(), 1);
487 clear_platform_file_picker();
488 }
489
490 #[test]
491 fn unsupported_operations_report_the_platform_gap() {
492 clear_platform_file_picker();
493 set_platform_file_picker(Rc::new(Marker));
494 let saved = pollster::block_on(
495 default_file_picker().save_document(SaveDocumentRequest::new("a.txt", "text/plain")),
496 );
497 let Err(error) = saved else {
498 panic!("the marker picker offers no save destination");
499 };
500 assert_eq!(error, FilePickerError::UnsupportedPlatform);
501 clear_platform_file_picker();
502 }
503}