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
163fn collect_files(entry: PickedEntryRef) -> PickerFuture<Vec<PickedEntryRef>> {
165 Box::pin(async move {
166 match entry.kind() {
167 PickedKind::File => vec![entry],
168 PickedKind::Folder => {
169 let mut files = Vec::new();
170 if let Ok(children) = entry.list().await {
171 for child in children {
172 files.extend(collect_files(child).await);
173 }
174 }
175 files
176 }
177 }
178 })
179}
180
181struct ReadyFolderStream {
185 ready: RefCell<Vec<PickedEntryRef>>,
186}
187
188impl FolderStream for ReadyFolderStream {
189 fn take_ready(&self) -> Vec<PickedEntryRef> {
190 std::mem::take(&mut self.ready.borrow_mut())
191 }
192
193 fn is_finished(&self) -> bool {
194 true
195 }
196
197 fn take_error(&self) -> Option<FilePickerError> {
198 None
199 }
200}
201
202fn eager_folder_stream(
206 folder: PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>,
207) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
208 Box::pin(async move {
209 match folder.await? {
210 None => Ok(None),
211 Some(entry) => {
212 let files = collect_files(entry).await;
213 Ok(Some(Rc::new(ReadyFolderStream {
214 ready: RefCell::new(files),
215 }) as FolderStreamRef))
216 }
217 }
218 })
219}
220
221pub trait FilePicker {
223 fn pick_file(
225 &self,
226 options: FilePickerOptions,
227 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
228
229 fn pick_folder(
231 &self,
232 options: FilePickerOptions,
233 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>>;
234
235 fn pick_folder_streaming(
245 &self,
246 options: FilePickerOptions,
247 ) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
248 eager_folder_stream(self.pick_folder(options))
249 }
250}
251
252pub type FilePickerRef = Rc<dyn FilePicker>;
254
255thread_local! {
256 static PLATFORM_FILE_PICKER: RefCell<Option<FilePickerRef>> = const { RefCell::new(None) };
257}
258
259pub fn set_platform_file_picker(picker: FilePickerRef) {
265 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
266}
267
268pub fn clear_platform_file_picker() {
270 PLATFORM_FILE_PICKER.with(|cell| *cell.borrow_mut() = None);
271}
272
273fn registered_platform_file_picker() -> Option<FilePickerRef> {
274 PLATFORM_FILE_PICKER.with(|cell| cell.borrow().clone())
275}
276
277struct PlatformFilePicker;
280
281impl FilePicker for PlatformFilePicker {
282 fn pick_file(
283 &self,
284 options: FilePickerOptions,
285 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
286 if let Some(picker) = registered_platform_file_picker() {
287 return picker.pick_file(options);
288 }
289 builtin_pick(options, PickedKind::File)
290 }
291
292 fn pick_folder(
293 &self,
294 options: FilePickerOptions,
295 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
296 if let Some(picker) = registered_platform_file_picker() {
297 return picker.pick_folder(options);
298 }
299 builtin_pick(options, PickedKind::Folder)
300 }
301
302 fn pick_folder_streaming(
303 &self,
304 options: FilePickerOptions,
305 ) -> PickerFuture<Result<Option<FolderStreamRef>, FilePickerError>> {
306 if let Some(picker) = registered_platform_file_picker() {
307 return picker.pick_folder_streaming(options);
308 }
309 eager_folder_stream(builtin_pick(options, PickedKind::Folder))
310 }
311}
312
313#[cfg_attr(
314 not(any(feature = "file-picker-native", feature = "file-picker-web")),
315 allow(unused_variables)
316)]
317fn builtin_pick(
318 options: FilePickerOptions,
319 kind: PickedKind,
320) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
321 #[cfg(all(
322 not(target_arch = "wasm32"),
323 not(target_os = "android"),
324 not(target_os = "ios"),
325 feature = "file-picker-native"
326 ))]
327 {
328 return desktop::pick(options, kind);
329 }
330
331 #[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
332 {
333 return web::pick(options, kind);
334 }
335
336 #[allow(unreachable_code)]
337 Box::pin(async move { Err(FilePickerError::UnsupportedPlatform) })
338}
339
340pub fn default_file_picker() -> FilePickerRef {
342 Rc::new(PlatformFilePicker)
343}
344
345pub fn local_file_picker() -> CompositionLocal<FilePickerRef> {
347 thread_local! {
348 static LOCAL_FILE_PICKER: RefCell<Option<CompositionLocal<FilePickerRef>>> = const { RefCell::new(None) };
349 }
350
351 LOCAL_FILE_PICKER.with(|cell| {
352 cell.borrow_mut()
353 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_file_picker, Rc::ptr_eq))
354 .clone()
355 })
356}
357
358#[allow(non_snake_case)]
360#[composable]
361pub fn ProvideFilePicker(content: impl FnOnce()) {
362 let picker = cranpose_core::remember(default_file_picker).with(|state| state.clone());
363 let picker_local = local_file_picker();
364
365 CompositionLocalProvider(vec![picker_local.provides(picker)], move || {
366 content();
367 });
368}
369
370#[cfg(all(
371 not(target_arch = "wasm32"),
372 not(target_os = "android"),
373 not(target_os = "ios"),
374 feature = "file-picker-native"
375))]
376mod desktop;
377
378#[cfg(all(target_arch = "wasm32", feature = "file-picker-web"))]
379mod web;
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn options_builder_sets_title_and_filters() {
387 let options = FilePickerOptions::default()
388 .with_title("Pick audio")
389 .with_filter(FileFilter::new("Audio", &["mp3", "flac"]));
390 assert_eq!(options.title.as_deref(), Some("Pick audio"));
391 assert_eq!(options.filters.len(), 1);
392 assert_eq!(options.filters[0].extensions, vec!["mp3", "flac"]);
393 }
394
395 #[test]
396 fn default_picker_is_created() {
397 let picker = default_file_picker();
398 assert_eq!(Rc::strong_count(&picker), 1);
399 }
400
401 #[test]
402 fn registered_platform_picker_takes_precedence() {
403 struct Marker;
404 impl FilePicker for Marker {
405 fn pick_file(
406 &self,
407 _options: FilePickerOptions,
408 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
409 Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
410 }
411 fn pick_folder(
412 &self,
413 _options: FilePickerOptions,
414 ) -> PickerFuture<Result<Option<PickedEntryRef>, FilePickerError>> {
415 Box::pin(async { Err(FilePickerError::Failed("marker".into())) })
416 }
417 }
418
419 clear_platform_file_picker();
420 assert!(registered_platform_file_picker().is_none());
421 set_platform_file_picker(Rc::new(Marker));
422 assert!(registered_platform_file_picker().is_some());
423
424 let result =
425 pollster::block_on(default_file_picker().pick_file(FilePickerOptions::default()));
426 assert!(matches!(result, Err(FilePickerError::Failed(_))));
427 clear_platform_file_picker();
428 }
429}