Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 20 kB View raw
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}