Another project
1use std::path::{Component, Path, PathBuf};
2
3use bone_ui::frame::FrameCtx;
4use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
5use bone_ui::widgets::{
6 FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, FilePickerOutcome,
7 FilePickerState, LabelText, WidgetPaint, show_file_picker,
8};
9use bone_ui::{WidgetId, WidgetKey};
10
11use crate::strings;
12
13pub const DEFAULT_DOCUMENTS_SUBDIR: &str = "bone-documents";
14
15#[derive(Copy, Clone, Debug, PartialEq, Eq)]
16pub enum FileKind {
17 Document,
18 Step,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct PickerEntry {
23 pub id: WidgetId,
24 pub label: String,
25 pub path: PathBuf,
26}
27
28pub struct FilePickerSession {
29 pub mode: FilePickerMode,
30 pub kind: FileKind,
31 pub root: PathBuf,
32 pub entries: Vec<PickerEntry>,
33 pub state: FilePickerState,
34}
35
36impl FilePickerSession {
37 #[must_use]
38 pub fn open(
39 root: PathBuf,
40 mode: FilePickerMode,
41 kind: FileKind,
42 seed_filename: Option<String>,
43 entries: Vec<PickerEntry>,
44 ) -> Self {
45 let mut state = FilePickerState::default();
46 if let Some(name) = seed_filename {
47 state.filename.text = name;
48 }
49 Self {
50 mode,
51 kind,
52 root,
53 entries,
54 state,
55 }
56 }
57}
58
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub enum PickerCommand {
61 Cancel,
62 Open(PathBuf),
63 SaveAs(PathBuf),
64}
65
66#[must_use]
67pub fn title_key(kind: FileKind, mode: FilePickerMode) -> bone_ui::strings::StringKey {
68 match (kind, mode) {
69 (FileKind::Document, FilePickerMode::Open) => strings::FILE_PICKER_TITLE_OPEN,
70 (FileKind::Document, FilePickerMode::Save) => strings::FILE_PICKER_TITLE_SAVE_AS,
71 (FileKind::Step, FilePickerMode::Open) => strings::FILE_PICKER_TITLE_IMPORT,
72 (FileKind::Step, FilePickerMode::Save) => strings::FILE_PICKER_TITLE_EXPORT,
73 }
74}
75
76#[must_use]
77pub fn accept_key(kind: FileKind, mode: FilePickerMode) -> bone_ui::strings::StringKey {
78 match (kind, mode) {
79 (FileKind::Document, FilePickerMode::Open) => strings::FILE_PICKER_OPEN,
80 (FileKind::Document, FilePickerMode::Save) => strings::FILE_PICKER_SAVE,
81 (FileKind::Step, FilePickerMode::Open) => strings::FILE_PICKER_IMPORT,
82 (FileKind::Step, FilePickerMode::Save) => strings::FILE_PICKER_EXPORT,
83 }
84}
85
86#[must_use]
87pub fn picker_labels(kind: FileKind, mode: FilePickerMode) -> FilePickerLabels {
88 let (list, filename_placeholder) = match kind {
89 FileKind::Document => (
90 strings::FILE_PICKER_LIST,
91 strings::FILE_PICKER_FILENAME_PLACEHOLDER,
92 ),
93 FileKind::Step => (
94 strings::FILE_PICKER_LIST_STEP,
95 strings::FILE_PICKER_FILENAME_PLACEHOLDER_STEP,
96 ),
97 };
98 FilePickerLabels {
99 title: title_key(kind, mode),
100 confirm: accept_key(kind, mode),
101 list,
102 filename_placeholder,
103 }
104}
105
106pub struct PickerModalOutcome {
107 pub paints: Vec<WidgetPaint>,
108 pub command: Option<PickerCommand>,
109}
110
111pub fn render(
112 ctx: &mut FrameCtx<'_>,
113 session: &mut FilePickerSession,
114 viewport: LayoutSize,
115) -> PickerModalOutcome {
116 let viewport_rect = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), viewport);
117 let entries: Vec<FilePickerEntry> = session
118 .entries
119 .iter()
120 .map(|e| FilePickerEntry {
121 id: e.id,
122 label: LabelText::Owned(e.label.clone()),
123 })
124 .collect();
125 let empty_key = match session.kind {
126 FileKind::Document => strings::FILE_PICKER_DIR_EMPTY,
127 FileKind::Step => strings::FILE_PICKER_NO_STEP_FILES,
128 };
129 let current_path = session.root.display().to_string();
130 let entries_empty = entries.is_empty();
131 let response = show_file_picker(
132 ctx,
133 FilePickerDialog::new(
134 picker_id(),
135 viewport_rect,
136 session.mode,
137 current_path,
138 &entries,
139 picker_labels(session.kind, session.mode),
140 &mut session.state,
141 ),
142 );
143 let mut paints = response.paint;
144 if entries_empty {
145 paints.push(empty_state_label(ctx, viewport_rect, empty_key));
146 }
147 let command = response
148 .outcome
149 .and_then(|o| translate(o, &session.entries, &session.root));
150 PickerModalOutcome { paints, command }
151}
152
153fn empty_state_label(
154 ctx: &FrameCtx<'_>,
155 viewport: LayoutRect,
156 key: bone_ui::strings::StringKey,
157) -> WidgetPaint {
158 let rect = LayoutRect::new(
159 LayoutPos::new(
160 LayoutPx::new(viewport.origin.x.value() + viewport.size.width.value() * 0.5 - 160.0),
161 LayoutPx::new(viewport.origin.y.value() + viewport.size.height.value() * 0.5 - 12.0),
162 ),
163 LayoutSize::new(LayoutPx::new(320.0), LayoutPx::new(24.0)),
164 );
165 WidgetPaint::Label {
166 rect,
167 text: LabelText::Key(key),
168 color: ctx.theme().colors.text_secondary(),
169 role: ctx.theme().typography.body,
170 }
171}
172
173fn translate(
174 outcome: FilePickerOutcome,
175 entries: &[PickerEntry],
176 root: &Path,
177) -> Option<PickerCommand> {
178 match outcome {
179 FilePickerOutcome::Cancelled => Some(PickerCommand::Cancel),
180 FilePickerOutcome::Open { folder } => entries
181 .iter()
182 .find(|e| e.id == folder)
183 .map(|e| PickerCommand::Open(e.path.clone())),
184 FilePickerOutcome::Save { folder, filename } => {
185 if filename.is_empty() {
186 return folder
187 .and_then(|f| entries.iter().find(|e| e.id == f))
188 .map(|e| PickerCommand::SaveAs(e.path.clone()));
189 }
190 let Some(name) = validate_save_name(filename.as_str()) else {
191 tracing::warn!(input = filename.as_str(), "rejected save filename");
192 return None;
193 };
194 Some(PickerCommand::SaveAs(root.join(name)))
195 }
196 }
197}
198
199const WINDOWS_RESERVED: &[&str] = &[
200 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
201 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
202];
203const WINDOWS_RESERVED_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
204pub const MAX_SAVE_NAME_BYTES: usize = 200;
205
206#[must_use]
207pub fn validate_save_name(name: &str) -> Option<&str> {
208 if name.is_empty() || name.trim().is_empty() {
209 return None;
210 }
211 if name.len() > MAX_SAVE_NAME_BYTES {
212 return None;
213 }
214 if name != name.trim() {
215 return None;
216 }
217 if name.ends_with('.') {
218 return None;
219 }
220 if name
221 .chars()
222 .any(|c| c.is_control() || WINDOWS_RESERVED_CHARS.contains(&c))
223 {
224 return None;
225 }
226 let stem = name.split('.').next().unwrap_or("");
227 if WINDOWS_RESERVED
228 .iter()
229 .any(|r| stem.eq_ignore_ascii_case(r))
230 {
231 return None;
232 }
233 let path = Path::new(name);
234 let mut components = path.components();
235 let first = components.next()?;
236 if components.next().is_some() {
237 return None;
238 }
239 match first {
240 Component::Normal(os) => os.to_str().filter(|s| !s.is_empty()),
241 Component::CurDir | Component::ParentDir | Component::Prefix(_) | Component::RootDir => {
242 None
243 }
244 }
245}
246
247pub fn picker_id() -> WidgetId {
248 WidgetId::ROOT.child(WidgetKey::new("app.file_picker"))
249}
250
251const STEP_EXTENSIONS: &[&str] = &["step", "stp"];
252
253#[must_use]
254pub fn is_step_file(path: &Path) -> bool {
255 path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
256 STEP_EXTENSIONS
257 .iter()
258 .any(|known| e.eq_ignore_ascii_case(known))
259 })
260}
261
262#[must_use]
263pub fn with_step_extension(path: PathBuf) -> PathBuf {
264 if is_step_file(&path) {
265 return path;
266 }
267 [path.into_os_string(), std::ffi::OsString::from(".step")]
268 .into_iter()
269 .collect::<std::ffi::OsString>()
270 .into()
271}
272
273pub fn scan_document_folders(root: &Path) -> Result<Vec<PickerEntry>, std::io::Error> {
274 scan_entries(root, "app.file_picker.entry", |path| {
275 path.is_dir()
276 && path
277 .join(bone_document::io::folder::DOCUMENT_FILE)
278 .is_file()
279 })
280}
281
282pub fn scan_step_files(root: &Path) -> Result<Vec<PickerEntry>, std::io::Error> {
283 scan_entries(root, "app.file_picker.step", |path| {
284 path.is_file() && is_step_file(path)
285 })
286}
287
288fn scan_entries(
289 root: &Path,
290 key: &'static str,
291 accept: impl Fn(&Path) -> bool,
292) -> Result<Vec<PickerEntry>, std::io::Error> {
293 let read = match std::fs::read_dir(root) {
294 Ok(read) => read,
295 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
296 Err(e) => return Err(e),
297 };
298 let mut entries: Vec<PickerEntry> = read
299 .filter_map(Result::ok)
300 .filter_map(|entry| {
301 let path = entry.path();
302 if !accept(&path) {
303 return None;
304 }
305 let label = path
306 .file_name()
307 .map(|s| s.to_string_lossy().into_owned())
308 .unwrap_or_default();
309 let id = WidgetId::ROOT
310 .child(WidgetKey::new(key))
311 .child_named(WidgetKey::new("name"), &label);
312 Some(PickerEntry { id, label, path })
313 })
314 .collect();
315 entries.sort_by(|a, b| a.label.cmp(&b.label));
316 Ok(entries)
317}
318
319#[must_use]
320pub fn documents_root() -> PathBuf {
321 if let Some(path) = std::env::var_os("BONE_DOCUMENTS_DIR") {
322 return PathBuf::from(path);
323 }
324 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
325 cwd.join(DEFAULT_DOCUMENTS_SUBDIR)
326}
327
328#[cfg(test)]
329mod tests {
330 use super::{
331 FileKind, FilePickerSession, PickerCommand, is_step_file, scan_document_folders,
332 scan_step_files, translate, validate_save_name, with_step_extension,
333 };
334 use bone_ui::widgets::{FilePickerMode, FilePickerOutcome};
335 use std::fs;
336 use std::path::PathBuf;
337
338 fn tmp(prefix: &str) -> PathBuf {
339 let nonce = std::time::SystemTime::now()
340 .duration_since(std::time::UNIX_EPOCH)
341 .map(|d| d.as_nanos())
342 .unwrap_or_default();
343 let base = std::env::temp_dir().join(format!("bone-file-menu-{prefix}-{nonce}"));
344 match fs::create_dir_all(&base) {
345 Ok(()) => base,
346 Err(e) => panic!("temp dir create failed: {e}"),
347 }
348 }
349
350 fn write_stub(path: &std::path::Path, body: &str) {
351 if let Some(parent) = path.parent()
352 && let Err(e) = fs::create_dir_all(parent)
353 {
354 panic!("create parent {}: {e}", parent.display());
355 }
356 if let Err(e) = fs::write(path, body) {
357 panic!("write stub {}: {e}", path.display());
358 }
359 }
360
361 const RON_STUB: &str = "(schema:(name:\"bone-document\",version:(major:1,minor:0)))";
362
363 fn scan(root: &std::path::Path) -> Vec<super::PickerEntry> {
364 match scan_document_folders(root) {
365 Ok(v) => v,
366 Err(e) => panic!("scan {}: {e}", root.display()),
367 }
368 }
369
370 #[test]
371 fn scan_picks_only_document_folders() {
372 let root = tmp("scan");
373 write_stub(&root.join("doc_a/document.ron"), "stub");
374 write_stub(&root.join("doc_b/document.ron"), "stub");
375 write_stub(&root.join("not_a_doc/.keep"), "");
376 write_stub(&root.join("loose-file.txt"), "x");
377 let entries = scan(&root);
378 assert_eq!(entries.len(), 2);
379 let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
380 assert_eq!(labels, ["doc_a", "doc_b"]);
381 }
382
383 #[test]
384 fn scan_missing_root_is_ok_empty() {
385 let root = tmp("scan-missing").join("does_not_exist");
386 let entries = scan_document_folders(&root);
387 assert!(matches!(entries, Ok(v) if v.is_empty()));
388 }
389
390 #[test]
391 fn scan_ids_are_stable_across_reruns() {
392 let root = tmp("scan-stable");
393 write_stub(&root.join("alpha/document.ron"), RON_STUB);
394 write_stub(&root.join("beta/document.ron"), RON_STUB);
395 let first = scan(&root);
396 let second = scan(&root);
397 assert_eq!(
398 first.iter().map(|e| e.id).collect::<Vec<_>>(),
399 second.iter().map(|e| e.id).collect::<Vec<_>>(),
400 );
401 }
402
403 #[test]
404 fn open_session_seeds_filename() {
405 let root = tmp("seed");
406 let session = FilePickerSession::open(
407 root,
408 FilePickerMode::Save,
409 FileKind::Document,
410 Some("Untitled".to_owned()),
411 Vec::new(),
412 );
413 assert_eq!(session.state.filename.text, "Untitled");
414 }
415
416 #[test]
417 fn translate_open_resolves_widget_id_to_path() {
418 let root = tmp("translate-open");
419 write_stub(&root.join("alpha/document.ron"), RON_STUB);
420 let entries = scan(&root);
421 let cmd = translate(
422 FilePickerOutcome::Open {
423 folder: entries[0].id,
424 },
425 &entries,
426 &root,
427 );
428 assert_eq!(cmd, Some(PickerCommand::Open(entries[0].path.clone())));
429 }
430
431 #[test]
432 fn scan_step_files_picks_only_step_extensions() {
433 let root = tmp("scan-step");
434 write_stub(&root.join("anemone.step"), "ISO-10303-21;");
435 write_stub(&root.join("barnacle.STP"), "ISO-10303-21;");
436 write_stub(&root.join("limpet.txt"), "x");
437 write_stub(&root.join("whelk.step/document.ron"), RON_STUB);
438 let entries = match scan_step_files(&root) {
439 Ok(v) => v,
440 Err(e) => panic!("scan {}: {e}", root.display()),
441 };
442 let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
443 assert_eq!(labels, ["anemone.step", "barnacle.STP"]);
444 }
445
446 #[test]
447 fn step_extension_recognized_case_insensitively() {
448 assert!(is_step_file(std::path::Path::new("conch.step")));
449 assert!(is_step_file(std::path::Path::new("conch.STEP")));
450 assert!(is_step_file(std::path::Path::new("conch.stp")));
451 assert!(!is_step_file(std::path::Path::new("conch.ron")));
452 assert!(!is_step_file(std::path::Path::new("conch")));
453 }
454
455 #[test]
456 fn step_extension_appended_without_clobbering_stem() {
457 assert_eq!(
458 with_step_extension(PathBuf::from("/tmp/mussel")),
459 PathBuf::from("/tmp/mussel.step"),
460 );
461 assert_eq!(
462 with_step_extension(PathBuf::from("/tmp/mussel.v2")),
463 PathBuf::from("/tmp/mussel.v2.step"),
464 );
465 assert_eq!(
466 with_step_extension(PathBuf::from("/tmp/mussel.STP")),
467 PathBuf::from("/tmp/mussel.STP"),
468 );
469 }
470
471 #[test]
472 fn translate_save_uses_filename_field_under_root() {
473 let root = tmp("translate-save");
474 let entries = scan(&root);
475 let cmd = translate(
476 FilePickerOutcome::Save {
477 folder: None,
478 filename: "brand_new".into(),
479 },
480 &entries,
481 &root,
482 );
483 assert_eq!(cmd, Some(PickerCommand::SaveAs(root.join("brand_new"))));
484 }
485
486 #[test]
487 fn translate_cancel_is_cancel() {
488 let root = tmp("translate-cancel");
489 let entries = scan(&root);
490 let cmd = translate(FilePickerOutcome::Cancelled, &entries, &root);
491 assert_eq!(cmd, Some(PickerCommand::Cancel));
492 }
493
494 #[test]
495 fn translate_save_empty_filename_falls_back_to_selection() {
496 let root = tmp("translate-save-fallback");
497 write_stub(&root.join("seed/document.ron"), RON_STUB);
498 let entries = scan(&root);
499 let cmd = translate(
500 FilePickerOutcome::Save {
501 folder: Some(entries[0].id),
502 filename: String::new(),
503 },
504 &entries,
505 &root,
506 );
507 assert_eq!(cmd, Some(PickerCommand::SaveAs(entries[0].path.clone())));
508 }
509
510 #[test]
511 fn translate_save_rejects_whitespace_only_filename() {
512 let root = tmp("translate-save-whitespace");
513 write_stub(&root.join("seed/document.ron"), RON_STUB);
514 let entries = scan(&root);
515 let cmd = translate(
516 FilePickerOutcome::Save {
517 folder: Some(entries[0].id),
518 filename: " ".into(),
519 },
520 &entries,
521 &root,
522 );
523 assert_eq!(cmd, None);
524 }
525
526 #[test]
527 fn validate_accepts_simple_name() {
528 assert_eq!(validate_save_name("Untitled"), Some("Untitled"));
529 assert_eq!(validate_save_name("doc_42"), Some("doc_42"));
530 assert_eq!(validate_save_name("a-b.c"), Some("a-b.c"));
531 }
532
533 #[test]
534 fn validate_rejects_path_escape_and_separators() {
535 assert_eq!(validate_save_name(""), None);
536 assert_eq!(validate_save_name(".."), None);
537 assert_eq!(validate_save_name("."), None);
538 assert_eq!(validate_save_name("../escape"), None);
539 assert_eq!(validate_save_name("a/b"), None);
540 #[cfg(windows)]
541 assert_eq!(validate_save_name(r"a\b"), None);
542 #[cfg(unix)]
543 assert_eq!(validate_save_name("/abs/path"), None);
544 }
545
546 #[test]
547 fn validate_rejects_windows_reserved_stems() {
548 assert_eq!(validate_save_name("CON"), None);
549 assert_eq!(validate_save_name("con"), None);
550 assert_eq!(validate_save_name("NUL.txt"), None);
551 assert_eq!(validate_save_name("COM1"), None);
552 assert_eq!(validate_save_name("LPT9.bak"), None);
553 assert_eq!(validate_save_name("AUX"), None);
554 }
555
556 #[test]
557 fn validate_rejects_windows_reserved_chars() {
558 assert_eq!(validate_save_name("foo<bar"), None);
559 assert_eq!(validate_save_name("a:b"), None);
560 assert_eq!(validate_save_name("pipe|name"), None);
561 assert_eq!(validate_save_name("ask?"), None);
562 assert_eq!(validate_save_name("star*"), None);
563 assert_eq!(validate_save_name("quote\""), None);
564 assert_eq!(validate_save_name("ctrl\x07char"), None);
565 }
566
567 #[test]
568 fn validate_rejects_trailing_dot_or_space() {
569 assert_eq!(validate_save_name("trailing."), None);
570 assert_eq!(validate_save_name("trailing "), None);
571 assert_eq!(validate_save_name(" leading"), None);
572 assert_eq!(validate_save_name("middle.dots.ok"), Some("middle.dots.ok"));
573 }
574
575 #[test]
576 fn validate_rejects_names_exceeding_byte_cap() {
577 use super::MAX_SAVE_NAME_BYTES;
578 let at_cap: String = "a".repeat(MAX_SAVE_NAME_BYTES);
579 let over_cap: String = "a".repeat(MAX_SAVE_NAME_BYTES + 1);
580 assert!(validate_save_name(&at_cap).is_some());
581 assert_eq!(validate_save_name(&over_cap), None);
582 }
583
584 #[test]
585 fn translate_save_rejects_parent_traversal() {
586 let root = tmp("translate-save-traversal");
587 let entries = scan(&root);
588 let cmd = translate(
589 FilePickerOutcome::Save {
590 folder: None,
591 filename: "../escape".into(),
592 },
593 &entries,
594 &root,
595 );
596 assert_eq!(cmd, None);
597 }
598
599 #[test]
600 fn translate_save_rejects_nested_path() {
601 let root = tmp("translate-save-nested");
602 let entries = scan(&root);
603 let cmd = translate(
604 FilePickerOutcome::Save {
605 folder: None,
606 filename: "a/b".into(),
607 },
608 &entries,
609 &root,
610 );
611 assert_eq!(cmd, None);
612 }
613
614 #[test]
615 #[cfg(unix)]
616 fn translate_save_rejects_absolute_path() {
617 let root = tmp("translate-save-abs");
618 let entries = scan(&root);
619 let cmd = translate(
620 FilePickerOutcome::Save {
621 folder: None,
622 filename: "/etc/passwd".into(),
623 },
624 &entries,
625 &root,
626 );
627 assert_eq!(cmd, None);
628 }
629}