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
109pub trait PickedEntry {
118 fn name(&self) -> String;
120
121 fn kind(&self) -> PickedKind;
123
124 fn display_path(&self) -> String;
127
128 fn read_bytes(&self) -> PickerFuture<Result<Vec<u8>, FilePickerError>>;
130
131 fn list(&self) -> PickerFuture<Result<Vec<PickedEntryRef>, FilePickerError>>;
133}
134
135pub type PickedEntryRef = Rc<dyn PickedEntry>;
137
138pub trait FolderStream {
150 fn take_ready(&self) -> Vec<PickedEntryRef>;
152
153 fn is_finished(&self) -> bool;
155
156 fn take_error(&self) -> Option<FilePickerError>;
158}
159
160pub type FolderStreamRef = Rc<dyn FolderStream>;
162
163pub enum ResumedPick {
168 File(PickedEntryRef),
170 Folder(FolderStreamRef),
172}
173
174fn collect_files(entry: PickedEntryRef) -> PickerFuture<Vec<PickedEntryRef>> {
176 Box::pin(async move {
177 match entry.kind() {
178 PickedKind::File => vec![entry],
179 PickedKind::Folder => {
180 let mut files = Vec::new();
181 if let Ok(children) = entry.list().await {
182 for child in children {
183 files.extend(collect_files(child).await);
184 }
185 }
186 files
187 }
188 }
189 })
190}
191
192struct ReadyFolderStream {
196 ready: RefCell<Vec<PickedEntryRef>>,
197}
198
199impl FolderStream for ReadyFolderStream {
200 fn take_ready(&self) -> Vec<PickedEntryRef> {
201 std::mem::take(&mut self.ready.borrow_mut())
202 }
203
204 fn is_finished(&self) -> bool {
205 true
206 }
207
208 fn take_error(&self) -> Option<FilePickerError> {
209 None
210 }
211}
212
213fn eager_folder_stream(
217 folder: PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>,
218) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
219 Box::pin(async move {
220 match folder.await? {
221 None => Ok(None),
222 Some(entry) => {
223 let files = collect_files(entry).await;
224 Ok(Some(Rc::new(ReadyFolderStream {
225 ready: RefCell::new(files),
226 }) as FolderStreamRef))
227 }
228 }
229 })
230}
231
232pub trait FilePicker {
234 fn pick_file(
236 &self,
237 options: FilePickerOptions,
238 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
239
240 fn pick_folder(
242 &self,
243 options: FilePickerOptions,
244 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
245
246 fn pick_folder_streaming(
256 &self,
257 options: FilePickerOptions,
258 ) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
259 eager_folder_stream(self.pick_folder(options))
260 }
261
262 fn take_resumed_picks(&self) -> Vec<ResumedPick> {
270 Vec::new()
271 }
272}
273
274pub type FilePickerRef = Rc<dyn FilePicker>;
276
277thread_local! {
278 static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
279}
280
281pub fn set_platform_file_picker(picker: FilePickerRef) {
287 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
288}
289
290pub fn clear_platform_file_picker() {
292 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
293}
294
295fn registered_platform_file_picker() -> Option<FilePickerRef> {
296 PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
297}
298
299struct PlatformFilePicker;
302
303impl FilePicker for PlatformFilePicker {
304 fn pick_file(
305 &self,
306 options: FilePickerOptions,
307 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
308 if let Some(picker) = registered_platform_file_picker() {
309 return picker.pick_file(options);
310 }
311 builtin_pick(options, PickedKind::File)
312 }
313
314 fn pick_folder(
315 &self,
316 options: FilePickerOptions,
317 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
318 if let Some(picker) = registered_platform_file_picker() {
319 return picker.pick_folder(options);
320 }
321 builtin_pick(options, PickedKind::Folder)
322 }
323
324 fn pick_folder_streaming(
325 &self,
326 options: FilePickerOptions,
327 ) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
328 if let Some(picker) = registered_platform_file_picker() {
329 return picker.pick_folder_streaming(options);
330 }
331 eager_folder_stream(builtin_pick(options, PickedKind::Folder))
332 }
333
334 fn take_resumed_picks(&self) -> Vec<ResumedPick> {
335 if let Some(picker) = registered_platform_file_picker() {
336 return picker.take_resumed_picks();
337 }
338 Vec::new()
339 }
340}
341
342#[cfg_attr(
343 not(any(feature = "file-picker-native", feature = "file-picker-web")),
344 allow(unused_variables)
345)]
346fn builtin_pick(
347 options: FilePickerOptions,
348 kind: PickedKind,
349) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
350 #[cfg(all(
351 not(target_arch = "wasm32"),
352 not(target_os = "android"),
353 not(target_os = "ios"),
354 feature = "file-picker-native"
355 ))]
356 {
357 return desktop::pick(options, kind);
358 }
359
360 #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
361 {
362 return web::pick(options, kind);
363 }
364
365 #[allow(unreachable_code)]
366 Box::pin(async move { Err(FilePickerError::UnsupportedPlatform) })
367}
368
369pub fn default_file_picker() -> FilePickerRef {
371 Rc::new(PlatformFilePicker)
372}
373
374pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
376 thread_local! {
377 static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
378 }
379
380 LOCAL_FILE_PICKER.with(|cell| {
381 cell.borrow_mut()
382 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
383 .clone()
384 })
385}
386
387#[allow(non_snake_case)]
389#[composable]
390pub fn ProvideFilePicker(content: impl FnOnce()) {
391 let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
392 let picker_local = local_file_picker();
393
394 CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
395 content();
396 });
397}
398
399#[cfg(all(
400 not(target_arch = "wasm32"),
401 not(target_os = "android"),
402 not(target_os = "ios"),
403 feature = "file-picker-native"
404))]
405mod desktop;
406
407#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
408mod web;
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn options_builder_sets_title_and_filters() {
416 let options = FilePickerOptions::default()
417 .with_title("Pick audio")
418 .with_filter(FileFilter::new("Audio", &["mp3", "flac"]));
419 assert_eq!(options.title.as_deref(), Some("Pick audio"));
420 assert_eq!(options.filters.len(), 1);
421 assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
422 }
423
424 #[test]
425 fn default_picker_is_created() {
426 let picker = default_file_picker();
427 assert_eq!(Rc::strong_count(&picker), 1);
428 }
429
430 #[test]
431 fn registered_platform_picker_takes_precedence() {
432 struct Marker;
433 impl FilePicker for Marker {
434 fn pick_file(
435 &self,
436 _options: FilePickerOptions,
437 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
438 Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
439 }
440 fn pick_folder(
441 &self,
442 _options: FilePickerOptions,
443 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
444 Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
445 }
446 }
447
448 clear_platform_file_picker();
449 assert!(registered_platform_file_picker().is_none());
450 set_platform_file_picker(Rc::new(Marker));
451 assert!(registered_platform_file_picker().is_some());
452
453 let result =
454 pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()));
455 assert!(matches!(result, Err(FilePickerError::Failed(_))));
456 clear_platform_file_picker();
457 }
458}