1use cranpose_core::compositionLocalOfWithPolicy;
17use cranpose_core::CompositionLocal;
18use cranpose_core::CompositionLocalProvider;
19use cranpose_macros::composable;
20use std::cell::RefCell;
21use std::future::Future;
22use std::pin::Pin;
23use std::rc::Rc;
24
25#[derive(thiserror::Error, Debug, Clone)]
27pub enum FilePickerError {
28 #[error("file picker failed: {0}")]
30 Failed(String),
31 #[error("reading picked entry failed: {0}")]
33 ReadFailed(String),
34 #[error("picked entry is a {actual}, expected a {expected}")]
36 WrongKind {
37 actual: &'static str,
39 expected: &'static str,
41 },
42 #[error("{operation} requires cranpose-services feature `{feature}`")]
44 UnsupportedFeature {
45 operation: &'static str,
47 feature: &'static str,
49 },
50 #[error("file picking is not available on this platform")]
52 UnsupportedPlatform,
53}
54
55pub type PickerFuture<T> = Pin<Box<dyn Future<Output = T>>>;
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub enum PickedKind {
61 File,
63 Folder,
65}
66
67#[derive(Clone, Debug, Default)]
69pub struct FileFilter {
70 pub label: String,
72 pub extensions: Vec<String>,
74}
75
76impl FileFilter {
77 pub fn new(label: impl Into<String>, extensions: &[&str]) -> Self {
79 Self {
80 label: label.into(),
81 extensions: extensions.iter().map(|ext| (*ext).to_string()).collect(),
82 }
83 }
84}
85
86#[derive(Clone, Debug, Default)]
88pub struct FilePickerOptions {
89 pub title: Option<String>,
91 pub filters: Vec<FileFilter>,
93}
94
95impl FilePickerOptions {
96 pub fn with_title(mut self, title: impl Into<String>) -> Self {
98 self.title = Some(title.into());
99 self
100 }
101
102 pub fn with_filter(mut self, filter: FileFilter) -> Self {
104 self.filters.push(filter);
105 self
106 }
107}
108
109#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct SaveFileRequest {
112 pub file_name: String,
114 pub mime_type: String,
117 pub bytes: Vec<u8>,
119}
120
121impl SaveFileRequest {
122 pub fn new(file_name: impl Into<String>, mime_type: impl Into<String>, bytes: Vec<u8>) -> Self {
123 Self {
124 file_name: file_name.into(),
125 mime_type: mime_type.into(),
126 bytes,
127 }
128 }
129}
130
131pub trait PickedEntry {
140 fn name(&self) -> String;
142
143 fn kind(&self) -> PickedKind;
145
146 fn display_path(&self) -> String;
149
150 fn read_bytes(&self) -> PickerFuture<Result<Vec<u8>, FilePickerError>>;
152
153 fn list(&self) -> PickerFuture<Result<Vec<PickedEntryRef>, FilePickerError>>;
155}
156
157pub type PickedEntryRef = Rc<dyn PickedEntry>;
159
160pub trait FolderStream {
172 fn take_ready(&self) -> Vec<PickedEntryRef>;
174
175 fn is_finished(&self) -> bool;
177
178 fn take_error(&self) -> Option<FilePickerError>;
180}
181
182pub type FolderStreamRef = Rc<dyn FolderStream>;
184
185pub enum ResumedPick {
190 File(PickedEntryRef),
192 Folder(FolderStreamRef),
194}
195
196fn collect_files(entry: PickedEntryRef) -> PickerFuture<Vec<PickedEntryRef>> {
198 Box::pin(async move {
199 match entry.kind() {
200 PickedKind::File => vec![entry],
201 PickedKind::Folder => {
202 let mut files = Vec::new();
203 if let Ok(children) = entry.list().await {
204 for child in children {
205 files.extend(collect_files(child).await);
206 }
207 }
208 files
209 }
210 }
211 })
212}
213
214struct ReadyFolderStream {
218 ready: RefCell<Vec<PickedEntryRef>>,
219}
220
221impl FolderStream for ReadyFolderStream {
222 fn take_ready(&self) -> Vec<PickedEntryRef> {
223 std::mem::take(&mut self.ready.borrow_mut())
224 }
225
226 fn is_finished(&self) -> bool {
227 true
228 }
229
230 fn take_error(&self) -> Option<FilePickerError> {
231 None
232 }
233}
234
235fn eager_folder_stream(
239 folder: PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>,
240) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
241 Box::pin(async move {
242 match folder.await? {
243 None => Ok(None),
244 Some(entry) => {
245 let files = collect_files(entry).await;
246 Ok(Some(Rc::new(ReadyFolderStream {
247 ready: RefCell::new(files),
248 }) as FolderStreamRef))
249 }
250 }
251 })
252}
253
254pub trait FilePicker {
256 fn pick_file(
258 &self,
259 options: FilePickerOptions,
260 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
261
262 fn pick_folder(
264 &self,
265 options: FilePickerOptions,
266 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
267
268 fn pick_folder_streaming(
278 &self,
279 options: FilePickerOptions,
280 ) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
281 eager_folder_stream(self.pick_folder(options))
282 }
283
284 fn take_resumed_picks(&self) -> Vec<ResumedPick> {
292 Vec::new()
293 }
294
295 fn save_file(&self, request: SaveFileRequest) -> PickerFuture<Result<bool, FilePickerError>> {
300 let _ = request;
301 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
302 }
303}
304
305pub type FilePickerRef = Rc<dyn FilePicker>;
307
308thread_local! {
309 static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
310}
311
312pub fn set_platform_file_picker(picker: FilePickerRef) {
318 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
319}
320
321pub fn clear_platform_file_picker() {
323 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
324}
325
326fn registered_platform_file_picker() -> Option<FilePickerRef> {
327 PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
328}
329
330struct PlatformFilePicker;
333
334impl FilePicker for PlatformFilePicker {
335 fn pick_file(
336 &self,
337 options: FilePickerOptions,
338 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
339 if let Some(picker) = registered_platform_file_picker() {
340 return picker.pick_file(options);
341 }
342 builtin_pick(options, PickedKind::File)
343 }
344
345 fn pick_folder(
346 &self,
347 options: FilePickerOptions,
348 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
349 if let Some(picker) = registered_platform_file_picker() {
350 return picker.pick_folder(options);
351 }
352 builtin_pick(options, PickedKind::Folder)
353 }
354
355 fn pick_folder_streaming(
356 &self,
357 options: FilePickerOptions,
358 ) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
359 if let Some(picker) = registered_platform_file_picker() {
360 return picker.pick_folder_streaming(options);
361 }
362 eager_folder_stream(builtin_pick(options, PickedKind::Folder))
363 }
364
365 fn take_resumed_picks(&self) -> Vec<ResumedPick> {
366 if let Some(picker) = registered_platform_file_picker() {
367 return picker.take_resumed_picks();
368 }
369 Vec::new()
370 }
371
372 fn save_file(&self, request: SaveFileRequest) -> PickerFuture<Result<bool, FilePickerError>> {
373 if let Some(picker) = registered_platform_file_picker() {
374 return picker.save_file(request);
375 }
376 builtin_save(request)
377 }
378}
379
380#[cfg_attr(
381 not(any(feature = "file-picker-native", feature = "file-picker-web")),
382 allow(unused_variables)
383)]
384fn builtin_pick(
385 options: FilePickerOptions,
386 kind: PickedKind,
387) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
388 #[cfg(all(
389 not(target_arch = "wasm32"),
390 not(target_os = "android"),
391 not(target_os = "ios"),
392 feature = "file-picker-native"
393 ))]
394 {
395 return desktop::pick(options, kind);
396 }
397
398 #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
399 {
400 return web::pick(options, kind);
401 }
402
403 #[allow(unreachable_code)]
404 Box::pin(async move { Err(FilePickerError::UnsupportedPlatform) })
405}
406
407#[cfg_attr(
408 not(any(feature = "file-picker-native", feature = "file-picker-web")),
409 allow(unused_variables)
410)]
411fn builtin_save(request: SaveFileRequest) -> PickerFuture<Result<bool, FilePickerError>> {
412 #[cfg(all(
413 not(target_arch = "wasm32"),
414 not(target_os = "android"),
415 not(target_os = "ios"),
416 feature = "file-picker-native"
417 ))]
418 {
419 return desktop::save(request);
420 }
421
422 #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
423 {
424 return web::save(request);
425 }
426
427 #[allow(unreachable_code)]
428 Box::pin(async move { Err(FilePickerError::UnsupportedPlatform) })
429}
430
431pub fn default_file_picker() -> FilePickerRef {
433 Rc::new(PlatformFilePicker)
434}
435
436pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
438 thread_local! {
439 static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
440 }
441
442 LOCAL_FILE_PICKER.with(|cell| {
443 cell.borrow_mut()
444 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
445 .clone()
446 })
447}
448
449#[allow(non_snake_case)]
451#[composable]
452pub fn ProvideFilePicker(content: impl FnOnce()) {
453 let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
454 let picker_local = local_file_picker();
455
456 CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
457 content();
458 });
459}
460
461#[cfg(all(
462 not(target_arch = "wasm32"),
463 not(target_os = "android"),
464 not(target_os = "ios"),
465 feature = "file-picker-native"
466))]
467mod desktop;
468
469#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
470mod web;
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn options_builder_sets_title_and_filters() {
478 let options = FilePickerOptions::default()
479 .with_title("Pick audio")
480 .with_filter(FileFilter::new("Audio", &["mp3", "flac"]));
481 assert_eq!(options.title.as_deref(), Some("Pick audio"));
482 assert_eq!(options.filters.len(), 1);
483 assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
484 }
485
486 #[test]
487 fn default_picker_is_created() {
488 let picker = default_file_picker();
489 assert_eq!(Rc::strong_count(&picker), 1);
490 }
491
492 #[test]
493 fn registered_platform_picker_takes_precedence() {
494 struct Marker;
495 impl FilePicker for Marker {
496 fn pick_file(
497 &self,
498 _options: FilePickerOptions,
499 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
500 Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
501 }
502 fn pick_folder(
503 &self,
504 _options: FilePickerOptions,
505 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
506 Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
507 }
508 }
509
510 clear_platform_file_picker();
511 assert!(registered_platform_file_picker().is_none());
512 set_platform_file_picker(Rc::new(Marker));
513 assert!(registered_platform_file_picker().is_some());
514
515 let result =
516 pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()));
517 assert!(matches!(result, Err(FilePickerError::Failed(_))));
518 clear_platform_file_picker();
519 }
520}