Another project
1use std::path::PathBuf;
2use std::sync::mpsc::{Receiver, TryRecvError};
3
4use bone_ui::widgets::FilePickerMode;
5
6use crate::file_menu::FileKind;
7
8#[derive(Debug)]
9pub enum NativeOutcome {
10 Path(PathBuf),
11 Cancelled,
12 Error(String),
13}
14
15#[derive(Debug)]
16pub enum SpawnError {
17 Unsupported,
18}
19
20pub struct PendingHandle {
21 rx: Receiver<NativeOutcome>,
22 pub mode: FilePickerMode,
23 pub kind: FileKind,
24}
25
26impl PendingHandle {
27 pub fn poll(&self) -> std::task::Poll<NativeOutcome> {
28 match self.rx.try_recv() {
29 Ok(outcome) => std::task::Poll::Ready(outcome),
30 Err(TryRecvError::Empty) => std::task::Poll::Pending,
31 Err(TryRecvError::Disconnected) => std::task::Poll::Ready(NativeOutcome::Error(
32 "picker worker disconnected".to_owned(),
33 )),
34 }
35 }
36}
37
38#[derive(Copy, Clone)]
39pub struct Request<'a> {
40 pub mode: FilePickerMode,
41 pub kind: FileKind,
42 pub title: &'a str,
43 pub accept_label: &'a str,
44 pub seed_filename: Option<&'a str>,
45 pub current_folder: Option<&'a std::path::Path>,
46}
47
48#[cfg(target_os = "linux")]
49pub fn spawn(req: Request<'_>) -> Result<PendingHandle, SpawnError> {
50 linux_impl::spawn(req)
51}
52
53#[cfg(not(target_os = "linux"))]
54pub fn spawn(_req: Request<'_>) -> Result<PendingHandle, SpawnError> {
55 Err(SpawnError::Unsupported)
56}
57
58#[cfg(target_os = "linux")]
59mod linux_impl {
60 use std::path::PathBuf;
61 use std::sync::mpsc;
62
63 use ashpd::desktop::file_chooser::{FileFilter, OpenFileRequest, SaveFileRequest};
64 use bone_ui::widgets::FilePickerMode;
65
66 use super::{FileKind, NativeOutcome, PendingHandle, Request, SpawnError};
67
68 pub(super) fn spawn(req: Request<'_>) -> Result<PendingHandle, SpawnError> {
69 let mode = req.mode;
70 let kind = req.kind;
71 let title = req.title.to_owned();
72 let accept = req.accept_label.to_owned();
73 let seed = req.seed_filename.map(str::to_owned);
74 let folder = req.current_folder.map(PathBuf::from);
75 let (tx, rx) = mpsc::channel();
76 let join = std::thread::Builder::new()
77 .name("bone-native-picker".to_owned())
78 .spawn(move || {
79 let outcome = pollster::block_on(run(mode, kind, &title, &accept, seed, folder));
80 let _ = tx.send(outcome);
81 });
82 match join {
83 Ok(_handle) => Ok(PendingHandle { rx, mode, kind }),
84 Err(_) => Err(SpawnError::Unsupported),
85 }
86 }
87
88 async fn run(
89 mode: FilePickerMode,
90 kind: FileKind,
91 title: &str,
92 accept: &str,
93 seed: Option<String>,
94 folder: Option<PathBuf>,
95 ) -> NativeOutcome {
96 match mode {
97 FilePickerMode::Open => run_open(kind, title, accept, folder).await,
98 FilePickerMode::Save => run_save(kind, title, accept, seed, folder).await,
99 }
100 }
101
102 fn step_filter() -> FileFilter {
103 FileFilter::new("STEP")
104 .glob("*.step")
105 .glob("*.stp")
106 .glob("*.STEP")
107 .glob("*.STP")
108 }
109
110 async fn run_open(
111 kind: FileKind,
112 title: &str,
113 accept: &str,
114 folder: Option<PathBuf>,
115 ) -> NativeOutcome {
116 let mut req = OpenFileRequest::default()
117 .title(title)
118 .accept_label(accept)
119 .modal(true)
120 .multiple(false)
121 .directory(matches!(kind, FileKind::Document));
122 if matches!(kind, FileKind::Step) {
123 req = req.filter(step_filter());
124 }
125 if let Some(f) = folder {
126 req = match req.current_folder::<PathBuf>(Some(f)) {
127 Ok(r) => r,
128 Err(e) => return NativeOutcome::Error(e.to_string()),
129 };
130 }
131 let response = match req.send().await {
132 Ok(r) => r,
133 Err(e) => return classify_send_error(&e),
134 };
135 let selected = match response.response() {
136 Ok(s) => s,
137 Err(e) => return classify_response_error(&e),
138 };
139 match selected
140 .uris()
141 .first()
142 .and_then(|u| uri_to_path(u.as_str()))
143 {
144 Some(path) => NativeOutcome::Path(path),
145 None => NativeOutcome::Cancelled,
146 }
147 }
148
149 async fn run_save(
150 kind: FileKind,
151 title: &str,
152 accept: &str,
153 seed: Option<String>,
154 folder: Option<PathBuf>,
155 ) -> NativeOutcome {
156 let mut req = SaveFileRequest::default()
157 .title(title)
158 .accept_label(accept)
159 .modal(true);
160 if matches!(kind, FileKind::Step) {
161 req = req.filter(step_filter());
162 }
163 if let Some(s) = seed.as_deref() {
164 req = req.current_name(s);
165 }
166 if let Some(f) = folder {
167 req = match req.current_folder::<PathBuf>(Some(f)) {
168 Ok(r) => r,
169 Err(e) => return NativeOutcome::Error(e.to_string()),
170 };
171 }
172 let response = match req.send().await {
173 Ok(r) => r,
174 Err(e) => return classify_send_error(&e),
175 };
176 let selected = match response.response() {
177 Ok(s) => s,
178 Err(e) => return classify_response_error(&e),
179 };
180 match selected
181 .uris()
182 .first()
183 .and_then(|u| uri_to_path(u.as_str()))
184 {
185 Some(path) => NativeOutcome::Path(path),
186 None => NativeOutcome::Cancelled,
187 }
188 }
189
190 fn classify_send_error(e: &ashpd::Error) -> NativeOutcome {
191 NativeOutcome::Error(format!("portal send: {e}"))
192 }
193
194 fn classify_response_error(e: &ashpd::Error) -> NativeOutcome {
195 match e {
196 ashpd::Error::Response(ashpd::desktop::ResponseError::Cancelled) => {
197 NativeOutcome::Cancelled
198 }
199 other => NativeOutcome::Error(format!("portal response: {other}")),
200 }
201 }
202
203 fn uri_to_path(uri: &str) -> Option<PathBuf> {
204 let encoded = uri.strip_prefix("file://")?;
205 let decoded = percent_encoding::percent_decode_str(encoded).decode_utf8_lossy();
206 Some(PathBuf::from(decoded.as_ref()))
207 }
208
209 #[cfg(test)]
210 mod tests {
211 use super::uri_to_path;
212 use std::path::PathBuf;
213
214 #[test]
215 fn plain_path() {
216 assert_eq!(
217 uri_to_path("file:///home/nel/docs"),
218 Some(PathBuf::from("/home/nel/docs")),
219 );
220 }
221
222 #[test]
223 fn percent_decoded() {
224 assert_eq!(
225 uri_to_path("file:///home/nel/my%20docs"),
226 Some(PathBuf::from("/home/nel/my docs")),
227 );
228 }
229
230 #[test]
231 fn rejects_non_file_scheme() {
232 assert_eq!(uri_to_path("http://example.com/docs"), None);
233 }
234 }
235}