diff --git a/README.md b/README.md index 4bb40e5..c17107d 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ deep-stale variants. | Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root | | Hierarchy lock | Excludes the actor from selection, gizmos, multi-drag, structural drops, and mutating context actions | | Hierarchy context | Group selection, create authored local children below linked prefab roots, remove/reparent generated members through same-layer structural overrides, or unparent | -| File menu | New, Open, transactional Save/Save As, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes | +| File menu | New, non-blocking native Open/Save As, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes | | Status strip / Asset Details source-state chip | Inspect compact clean, modified, untracked, conflicted, read-only, and optional ownership status; hover for the source path and provider details | | Authored File Not Saved dialog | Resolve an external edit, read-only target, or ownership lock with Reload, Compare Metadata, Save As, or Cancel; the editor never offers force overwrite | | Main toolbar, right side | Switch or close independent scene tabs, create an untitled tab, and manage loaded/locked composed subscenes | @@ -398,6 +398,7 @@ crates/ - [x] Filtered hierarchy, inspector, viewport, toolbar, asset browser, and status panels - [x] Delete, duplicate, rename, and structural/material undo-redo - [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking +- [x] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52)) - [x] Asset import, static mesh/prefab placement, texture assignment, and selection export - [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop) - [x] Unified viewport render-to-texture target + Play session bootstrap diff --git a/crates/editor/src/assets/prefab_overrides.rs b/crates/editor/src/assets/prefab_overrides.rs index 257448f..08ddf82 100644 --- a/crates/editor/src/assets/prefab_overrides.rs +++ b/crates/editor/src/assets/prefab_overrides.rs @@ -22,6 +22,7 @@ use crate::asset_db::{ensure_asset_record, AssetRegistry}; use crate::history::{ capture_prefab_edit_state, record_prefab_edit_state, record_prefab_source_apply, }; +use crate::native_dialog::NativeDialogBroker; use crate::ui::inspector::{ apply_component_card_response, component_card, component_card_context, property_row, ComponentCardOptions, @@ -751,13 +752,44 @@ fn relink_prefab_source(world: &mut World, entity: Entity) { else { return; }; - let Some(path) = rfd::FileDialog::new() - .set_directory(project_root.join("assets/prefabs")) - .add_filter("Bevy prefab", &["scn.ron", "ron"]) - .pick_file() - else { + let directory = project_root.join("assets/prefabs"); + let result = world.resource::().request( + move || { + rfd::FileDialog::new() + .set_directory(directory) + .add_filter("Bevy prefab", &["scn.ron", "ron"]) + .pick_file() + }, + move |world, path| { + let Some(path) = path else { + world + .resource_mut::() + .set_status("Prefab relink cancelled"); + return; + }; + finish_relink_prefab_source(world, entity, project_root, path); + }, + ); + world + .resource_mut::() + .set_status(match result { + Ok(()) => "Choose a prefab source".into(), + Err(error) => format!("Prefab relink unavailable: {error}"), + }); +} + +fn finish_relink_prefab_source( + world: &mut World, + entity: Entity, + project_root: PathBuf, + path: PathBuf, +) { + if world.get_entity(entity).is_err() { + world + .resource_mut::() + .set_status("Prefab relink failed: the initiating actor no longer exists"); return; - }; + } let Ok(path) = path.canonicalize() else { return; }; diff --git a/crates/editor/src/project/native_dialog.rs b/crates/editor/src/project/native_dialog.rs index 939220c..f3a959b 100644 --- a/crates/editor/src/project/native_dialog.rs +++ b/crates/editor/src/project/native_dialog.rs @@ -95,6 +95,9 @@ mod tests { #[derive(Resource, Default)] struct ResultValue(Option); + #[derive(Resource, Default)] + struct FrameCount(u32); + #[test] fn request_is_exclusive_and_completion_runs_once() { let mut app = App::new(); @@ -125,4 +128,44 @@ mod tests { assert_eq!(app.world().resource::().0, Some(42)); assert!(!app.world().resource::().is_pending()); } + + #[test] + fn blocked_dialog_worker_does_not_block_app_frames() { + let mut app = App::new(); + app.add_plugins(NativeDialogPlugin) + .init_resource::() + .init_resource::() + .add_systems(Update, |mut frames: ResMut| frames.0 += 1); + let (release_sender, release_receiver) = mpsc::channel(); + app.world() + .resource::() + .request( + move || { + release_receiver + .recv() + .expect("test release sender dropped") + }, + |world, value| world.resource_mut::().0 = Some(value), + ) + .unwrap(); + + for _ in 0..8 { + app.update(); + } + assert_eq!(app.world().resource::().0, 8); + assert_eq!(app.world().resource::().0, None); + assert!(app.world().resource::().is_pending()); + + release_sender.send(73).unwrap(); + for _ in 0..100 { + app.update(); + if app.world().resource::().0.is_some() { + break; + } + std::thread::yield_now(); + } + assert_eq!(app.world().resource::().0, Some(73)); + assert!(app.world().resource::().0 > 8); + assert!(!app.world().resource::().is_pending()); + } } diff --git a/crates/editor/src/scene/scene_io.rs b/crates/editor/src/scene/scene_io.rs index c4dbb68..1904728 100644 --- a/crates/editor/src/scene/scene_io.rs +++ b/crates/editor/src/scene/scene_io.rs @@ -23,6 +23,7 @@ use shared::{ use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS}; use crate::history::{clear_level_objects, snapshot_entity, EditorHistory}; +use crate::native_dialog::NativeDialogBroker; use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent}; use crate::scene::recovery::{ default_state_root, discard_recovery_snapshots, latest_recovery_snapshot, @@ -82,8 +83,6 @@ pub struct SceneIo { pub request: Option, pub status: String, pub dirty: bool, - /// When set, the next I/O request needs unsaved-changes confirmation. - pub pending_request: Option, /// Newest recovery snapshot that is newer than the active authored scene. pub recovery_snapshot: Option, /// Bounded in-session audit trail for scene persistence and recovery operations. @@ -119,7 +118,6 @@ impl Default for SceneIo { request: None, status: "Ready".to_string(), dirty: false, - pending_request: None, recovery_snapshot: None, events: VecDeque::new(), tabs: vec![SceneTab { @@ -291,39 +289,15 @@ fn load_startup_scene(world: &mut World) { } fn process_scene_io_requests(world: &mut World) { - let request = { - let mut io = world.resource_mut::(); - if let Some(pending) = io.pending_request.take() { - pending - } else { - match io.request.take() { - Some(req) if io.has_unsaved_tabs() && needs_dirty_confirm(&req) => { - io.pending_request = Some(req); - return; - } - Some(req) => req, - None => return, - } - } + let Some(request) = world.resource_mut::().request.take() else { + return; }; - if world.resource::().has_unsaved_tabs() && needs_dirty_confirm(&request) { - match confirm_discard_or_save() { - DirtyConfirm::Save => { - let save_status = save_all_tabs(world); - if save_status.starts_with("Save failed") || save_status.ends_with("cancelled") { - world.resource_mut::().set_status(save_status); - return; - } - } - DirtyConfirm::Discard => {} - DirtyConfirm::Cancel => { - world - .resource_mut::() - .set_status("Operation cancelled"); - return; - } - } + if matches!(request, SceneIoRequest::SwitchProject) + && world.resource::().has_unsaved_tabs() + { + request_switch_project_confirmation(world); + return; } let status = match request { @@ -348,16 +322,6 @@ fn process_scene_io_requests(world: &mut World) { world.resource_mut::().set_status(status); } -enum DirtyConfirm { - Save, - Discard, - Cancel, -} - -fn needs_dirty_confirm(request: &SceneIoRequest) -> bool { - matches!(request, SceneIoRequest::SwitchProject) -} - fn switch_project(world: &mut World) -> String { match crate::launcher::spawn_project_launcher_process() { Ok(()) => { @@ -368,19 +332,41 @@ fn switch_project(world: &mut World) -> String { } } -fn confirm_discard_or_save() -> DirtyConfirm { - use rfd::{MessageButtons, MessageDialog, MessageLevel}; - match MessageDialog::new() - .set_title("Unsaved Changes") - .set_description("The scene has unsaved changes. Save before continuing?") - .set_level(MessageLevel::Warning) - .set_buttons(MessageButtons::YesNoCancel) - .show() - { - rfd::MessageDialogResult::Yes => DirtyConfirm::Save, - rfd::MessageDialogResult::No => DirtyConfirm::Discard, - _ => DirtyConfirm::Cancel, - } +fn request_switch_project_confirmation(world: &mut World) { + let result = world.resource::().request( + || { + rfd::MessageDialog::new() + .set_title("Unsaved Changes") + .set_description("The scene has unsaved changes. Save before continuing?") + .set_level(rfd::MessageLevel::Warning) + .set_buttons(rfd::MessageButtons::YesNoCancel) + .show() + }, + |world, decision| match decision { + rfd::MessageDialogResult::Yes => { + let status = save_all_tabs(world); + if status == "Saved all modified scene tabs" { + let status = switch_project(world); + world.resource_mut::().set_status(status); + } else { + world.resource_mut::().set_status(format!( + "Project switch paused until every scene is saved: {status}" + )); + } + } + rfd::MessageDialogResult::No => { + let status = switch_project(world); + world.resource_mut::().set_status(status); + } + _ => world + .resource_mut::() + .set_status("Project switch cancelled"), + }, + ); + world.resource_mut::().set_status(match result { + Ok(()) => "Waiting for unsaved-scene confirmation".into(), + Err(error) => format!("Project switch unavailable: {error}"), + }); } fn open_recent(world: &mut World, index: usize) -> String { @@ -533,18 +519,14 @@ fn close_scene_tab(world: &mut World, index: usize) -> String { } if world.resource::().dirty { - match confirm_close_scene() { - DirtyConfirm::Save => { - let status = save_active_or_prompt(world); - if status.starts_with("Save failed") || status.ends_with("cancelled") { - return status; - } - } - DirtyConfirm::Discard => {} - DirtyConfirm::Cancel => return "Close scene cancelled".to_string(), - } + request_close_scene_confirmation(world); + return "Waiting for close-scene confirmation".to_string(); } + finish_close_scene_tab(world) +} + +fn finish_close_scene_tab(world: &mut World) -> String { if world.resource::().tabs.len() == 1 { clear_scene_world(world); world.insert_resource(SceneComposition { @@ -618,18 +600,36 @@ fn reload_active_composition(world: &mut World) -> String { "Reloaded active scene composition".to_string() } -fn confirm_close_scene() -> DirtyConfirm { - use rfd::{MessageButtons, MessageDialog, MessageLevel}; - match MessageDialog::new() - .set_title("Close Scene") - .set_description("This scene tab has unsaved changes. Save before closing it?") - .set_level(MessageLevel::Warning) - .set_buttons(MessageButtons::YesNoCancel) - .show() - { - rfd::MessageDialogResult::Yes => DirtyConfirm::Save, - rfd::MessageDialogResult::No => DirtyConfirm::Discard, - _ => DirtyConfirm::Cancel, +fn request_close_scene_confirmation(world: &mut World) { + let result = world.resource::().request( + || { + rfd::MessageDialog::new() + .set_title("Close Scene") + .set_description("This scene tab has unsaved changes. Save before closing it?") + .set_level(rfd::MessageLevel::Warning) + .set_buttons(rfd::MessageButtons::YesNoCancel) + .show() + }, + |world, decision| { + let status = match decision { + rfd::MessageDialogResult::Yes => { + let save_status = save_active_or_prompt(world); + if save_status.starts_with("Saved ") { + finish_close_scene_tab(world) + } else { + format!("Close scene paused until it is saved: {save_status}") + } + } + rfd::MessageDialogResult::No => finish_close_scene_tab(world), + _ => "Close scene cancelled".to_string(), + }; + world.resource_mut::().set_status(status); + }, + ); + if let Err(error) = result { + world + .resource_mut::() + .set_status(format!("Close scene unavailable: {error}")); } } @@ -694,12 +694,31 @@ fn save_selection_as_prefab(world: &mut World) -> String { return "Select level objects to save as prefab".to_string(); } - let Some(path) = rfd::FileDialog::new() - .set_directory("assets/prefabs") - .add_filter("Bevy prefab", &["scn.ron", "ron"]) - .set_file_name("prefab.scn.ron") - .save_file() - else { + let request = world.resource::().request( + || { + rfd::FileDialog::new() + .set_directory("assets/prefabs") + .add_filter("Bevy prefab", &["scn.ron", "ron"]) + .set_file_name("prefab.scn.ron") + .save_file() + }, + move |world, path| { + let status = finish_save_selection_as_prefab(world, selection, path); + world.resource_mut::().set_status(status); + }, + ); + match request { + Ok(()) => "Choose a destination for the prefab".to_string(), + Err(error) => format!("Save prefab unavailable: {error}"), + } +} + +fn finish_save_selection_as_prefab( + world: &mut World, + selection: Vec, + path: Option, +) -> String { + let Some(path) = path else { return "Save prefab cancelled".to_string(); }; let expected = match FileSnapshot::capture(&path) { @@ -859,12 +878,27 @@ fn save_active_or_prompt(world: &mut World) -> String { } fn save_with_dialog(world: &mut World) -> String { - let Some(path) = rfd::FileDialog::new() - .set_directory("assets/levels") - .add_filter("Bevy scene", &["scn.ron", "ron"]) - .set_file_name("editor_scene.scn.ron") - .save_file() - else { + let request = world.resource::().request( + || { + rfd::FileDialog::new() + .set_directory("assets/levels") + .add_filter("Bevy scene", &["scn.ron", "ron"]) + .set_file_name("editor_scene.scn.ron") + .save_file() + }, + |world, path| { + let status = finish_save_with_dialog(world, path); + world.resource_mut::().set_status(status); + }, + ); + match request { + Ok(()) => "Choose a scene destination".to_string(), + Err(error) => format!("Save unavailable: {error}"), + } +} + +fn finish_save_with_dialog(world: &mut World, path: Option) -> String { + let Some(path) = path else { return "Save cancelled".to_string(); }; let expected = match FileSnapshot::capture(&path) { @@ -885,31 +919,51 @@ fn save_with_dialog(world: &mut World) -> String { } fn open_with_dialog(world: &mut World) -> String { - let Some(path) = rfd::FileDialog::new() - .set_directory("assets/levels") - .add_filter("Bevy scene", &["scn.ron", "ron"]) - .pick_file() - else { - return "Open cancelled".to_string(); - }; - - open_path(world, path) + let request = world.resource::().request( + || { + rfd::FileDialog::new() + .set_directory("assets/levels") + .add_filter("Bevy scene", &["scn.ron", "ron"]) + .pick_file() + }, + |world, path| { + let status = path.map_or_else( + || "Open cancelled".to_string(), + |path| open_path(world, path), + ); + world.resource_mut::().set_status(status); + }, + ); + match request { + Ok(()) => "Choose a scene to open".to_string(), + Err(error) => format!("Open unavailable: {error}"), + } } fn import_with_dialog(world: &mut World) -> String { - let Some(paths) = rfd::FileDialog::new() - .add_filter("Editor assets", IMPORTABLE_ASSET_EXTENSIONS) - .pick_files() - else { - return "Import cancelled".to_string(); - }; - - match import_external_assets(&paths) { - Ok(count) => { - world.resource_mut::().refresh(); - format!("Imported {count} asset(s)") - } - Err(err) => format!("Import failed: {err}"), + let request = world.resource::().request( + || { + rfd::FileDialog::new() + .add_filter("Editor assets", IMPORTABLE_ASSET_EXTENSIONS) + .pick_files() + }, + |world, paths| { + let status = match paths { + None => "Import cancelled".to_string(), + Some(paths) => match import_external_assets(&paths) { + Ok(count) => { + world.resource_mut::().refresh(); + format!("Imported {count} asset(s)") + } + Err(err) => format!("Import failed: {err}"), + }, + }; + world.resource_mut::().set_status(status); + }, + ); + match request { + Ok(()) => "Choose assets to import".to_string(), + Err(error) => format!("Import unavailable: {error}"), } } @@ -921,14 +975,32 @@ fn export_selection_with_dialog(world: &mut World) -> String { return "Selected entity is not an authored level object".to_string(); } - let Some(path) = rfd::FileDialog::new() - .set_directory("assets/levels") - .add_filter("Bevy prefab", &["scn.ron", "ron"]) - .set_file_name("selection.scn.ron") - .save_file() - else { + let request = world.resource::().request( + || { + rfd::FileDialog::new() + .set_directory("assets/levels") + .add_filter("Bevy prefab", &["scn.ron", "ron"]) + .set_file_name("selection.scn.ron") + .save_file() + }, + move |world, path| { + let status = finish_export_selection(world, entity, path); + world.resource_mut::().set_status(status); + }, + ); + match request { + Ok(()) => "Choose an export destination".to_string(), + Err(error) => format!("Export unavailable: {error}"), + } +} + +fn finish_export_selection(world: &mut World, entity: Entity, path: Option) -> String { + let Some(path) = path else { return "Export cancelled".to_string(); }; + if snapshot_entity(world, entity).is_none() { + return "Export failed: the initiating selection no longer exists".to_string(); + } let expected = match FileSnapshot::capture(&path) { Ok(expected) => expected, Err(error) => return format!("Export failed: {error}"), @@ -1607,12 +1679,32 @@ fn save_recovery_copy_with_dialog(world: &mut World) -> String { || "recovered_scene.scn.ron".to_string(), |stem| format!("{stem}.recovered.scn.ron"), ); - let Some(destination) = rfd::FileDialog::new() - .set_directory(directory) - .add_filter("Bevy scene", &["scn.ron", "ron"]) - .set_file_name(file_name) - .save_file() - else { + let directory = directory.to_path_buf(); + let request = world.resource::().request( + move || { + rfd::FileDialog::new() + .set_directory(directory) + .add_filter("Bevy scene", &["scn.ron", "ron"]) + .set_file_name(file_name) + .save_file() + }, + move |world, destination| { + let status = finish_save_recovery_copy(world, snapshot, destination); + world.resource_mut::().set_status(status); + }, + ); + match request { + Ok(()) => "Choose a destination for the recovery copy".to_string(), + Err(error) => format!("Save recovery copy unavailable: {error}"), + } +} + +fn finish_save_recovery_copy( + world: &mut World, + snapshot: PathBuf, + destination: Option, +) -> String { + let Some(destination) = destination else { return "Save recovery copy cancelled".to_string(); }; let expected = match FileSnapshot::capture(&destination) { diff --git a/crates/editor/src/ui/scene_tabs.rs b/crates/editor/src/ui/scene_tabs.rs index 694baa2..ebc3e48 100644 --- a/crates/editor/src/ui/scene_tabs.rs +++ b/crates/editor/src/ui/scene_tabs.rs @@ -8,6 +8,7 @@ use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use egui_phosphor_icons::icons; use shared::{SceneComposition, SubsceneReference}; +use crate::native_dialog::NativeDialogBroker; use crate::scene_io::{ComposedSceneMember, SceneIo, SceneIoRequest}; use crate::selection::SelectedEntity; @@ -195,19 +196,47 @@ fn composition_menu( ui.separator(); if ui - .add_enabled(editing, egui::Button::new("Add Subscene...")) + .add_enabled( + editing && !world.resource::().is_pending(), + egui::Button::new("Add Subscene..."), + ) .clicked() { - match choose_subscene(world, &composition) { - Ok(Some(reference)) => { - composition.subscenes.push(reference); - changed = true; - } - Ok(None) => {} - Err(error) => world - .resource_mut::() - .set_status(format!("Add subscene failed: {error}")), - } + let project_root = + PathBuf::from(&world.resource::().root); + let directory = project_root.join("assets/levels"); + let result = world.resource::().request( + move || { + rfd::FileDialog::new() + .set_directory(directory) + .add_filter("Bevy scene", &["scn.ron", "ron"]) + .pick_file() + }, + move |world, path| { + let Some(path) = path else { + world + .resource_mut::() + .set_status("Add subscene cancelled"); + return; + }; + let current = world.resource::().clone(); + match validate_subscene_choice(&project_root, ¤t, path) { + Ok(reference) => { + let mut updated = current; + updated.subscenes.push(reference); + crate::history::set_scene_composition_with_history(world, updated); + world.resource_mut::().set_status("Subscene added"); + } + Err(error) => world + .resource_mut::() + .set_status(format!("Add subscene failed: {error}")), + } + }, + ); + world.resource_mut::().set_status(match result { + Ok(()) => "Choose a scene to compose".into(), + Err(error) => format!("Add subscene unavailable: {error}"), + }); } if changed { @@ -218,20 +247,14 @@ fn composition_menu( } } -fn choose_subscene( - world: &World, +fn validate_subscene_choice( + project_root: &Path, composition: &SceneComposition, -) -> Result, String> { - let project_root = PathBuf::from(&world.resource::().root) + path: PathBuf, +) -> Result { + let project_root = project_root .canonicalize() .map_err(|error| format!("could not resolve project root: {error}"))?; - let Some(path) = rfd::FileDialog::new() - .set_directory(project_root.join("assets/levels")) - .add_filter("Bevy scene", &["scn.ron", "ron"]) - .pick_file() - else { - return Ok(None); - }; let path = path .canonicalize() .map_err(|error| format!("could not resolve {}: {error}", path.display()))?; @@ -251,12 +274,12 @@ fn choose_subscene( { return Err(format!("{relative} is already composed")); } - Ok(Some(SubsceneReference { + Ok(SubsceneReference { id: uuid::Uuid::new_v4().to_string(), path: relative, visible: true, locked: true, - })) + }) } fn focus_subscene(world: &mut World, selected: &mut SelectedEntities, reference_id: &str) { diff --git a/docs/README.md b/docs/README.md index fa529e2..4ebb35c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,6 +52,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [0035](adr/0035-shared-material-assets-and-renderer-slots.md) | Shared Material/Material Instance assets, stable renderer slots, and runtime-only property blocks | | [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary | | [0037](adr/0037-collaborative-authored-file-safety.md) | Exact authored-file revisions, observational Git status, and optional ownership providers | +| [0038](adr/0038-non-blocking-native-dialog-broker.md) | Worker-owned native waits with one-shot main-thread workflow completion | ## Editor framework @@ -77,6 +78,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract | | [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics | | [editor/collaborative-file-safety.md](editor/collaborative-file-safety.md) | Guarded authored writes, compact Git/read-only status, conflict recovery, and ownership providers | +| [editor/native-dialogs.md](editor/native-dialogs.md) | Non-blocking native dialog acquisition and main-thread result application | | [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation | | [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity | | [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements | diff --git a/docs/adr/0038-non-blocking-native-dialog-broker.md b/docs/adr/0038-non-blocking-native-dialog-broker.md new file mode 100644 index 0000000..41d491c --- /dev/null +++ b/docs/adr/0038-non-blocking-native-dialog-broker.md @@ -0,0 +1,40 @@ +# ADR 0038: Non-Blocking Native Dialog Broker + +## Status + +Accepted + +## Context + +Blacksite opens native file, folder, Save As, and confirmation dialogs from editor workflows. Calling +the synchronous `rfd` API inside a Bevy/egui system stops window event processing until the dialog +returns. On Wayland compositors this can trigger an application-not-responding prompt even though +the native dialog is operating normally. + +Path selection precedes stateful main-thread work such as scene serialization, imports, history +changes, prefab relinking, collaborative revision checks, and project activation. Moving those +operations to arbitrary worker threads would violate ECS ownership and weaken existing guards. + +## Decision + +The editor owns one `NativeDialogBroker` resource. A workflow submits native dialog construction and +a typed completion callback. The broker runs only the blocking native dialog wait on a worker thread, +then queues the result. An exclusive Bevy system drains the queue and invokes each completion exactly +once with `&mut World`. + +Only one native dialog may be active. Additional requests fail immediately with explicit status. +Cancellation is delivered as the dialog API's empty or cancel result and remains non-mutating. +Initiating workflows capture stable entity IDs or paths and revalidate them before applying a result. + +Native confirmation dialogs use the same broker. A requested close or project switch proceeds only +after the confirmation result and any required save have completed; an incomplete asynchronous save +pauses the transition rather than discarding content. + +## Consequences + +- Blacksite continues rendering and answering compositor pings while native dialogs are open. +- Project and ECS mutations remain on the Bevy main thread with their existing history, validation, + and collaborative file-revision boundaries. +- Workflows split dialog acquisition from result application and handle stale initiating state. +- The one-dialog policy is intentionally global across the editor and Project Browser. +- A future platform-specific async backend can replace the worker without changing completions. diff --git a/docs/editor/README.md b/docs/editor/README.md index ba14d86..5577c02 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -24,9 +24,11 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | [extensibility.md](extensibility.md) | Static authoring component lifecycle registration, stable IDs, composition, and generic history | | [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration | | [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers | +| [native-dialogs.md](native-dialogs.md) | Non-blocking file/folder/confirmation acquisition and main-thread result application | | [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation | | [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops | | [evaluations/collaborative-file-safety/](evaluations/collaborative-file-safety/) | Live screenshot and verification record for source-control status and guarded external-change recovery | +| [evaluations/native-dialog-responsiveness/](evaluations/native-dialog-responsiveness/) | Live native-Wayland screenshot and verification record for non-blocking picker responsiveness | | [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity | | [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence | @@ -48,6 +50,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 | | `project/` | Workspace, settings UI, user prefs, support diagnostics | roadmap Phase 1 | | `project/collaboration.rs` | Guarded authored writes, asynchronous Git status, and optional ownership providers | collaborative-file-safety.md, ADR 0037 | +| `project/native_dialog.rs` | One-at-a-time native dialog worker and one-shot main-thread completions | native-dialogs.md, ADR 0038 | | `project/session.rs` | Versioned XDG session document, clean marker, and safe resume | session-recovery.md, ADR 0024 | | `project/diagnostics_bundle.rs` | Privacy-bounded transactional support report export | session-recovery.md, ADR 0024 | | `project/launcher.rs` | Strict project inspection, CLI activation, recent filtering, and sandbox scaffolding | project-launcher.md, ADR 0025 | diff --git a/docs/editor/evaluations/native-dialog-responsiveness/README.md b/docs/editor/evaluations/native-dialog-responsiveness/README.md new file mode 100644 index 0000000..ab0f446 --- /dev/null +++ b/docs/editor/evaluations/native-dialog-responsiveness/README.md @@ -0,0 +1,42 @@ +# Native Dialog Responsiveness Evaluation + +Date: 2026-07-12 +Branch: `codex/non-blocking-native-dialogs` +Implementation commits: `d038cf3` and the closing commit for Gitea #52 + +This record captures live acceptance for the non-blocking native-dialog broker. The permanent +workflow contract lives in the [native-dialog guide](../../native-dialogs.md). + +## Live Editor Evidence + +The native KDE portal Open dialog below was launched through the production `SceneIoRequest::Open` +path in a native Wayland debug editor and held open for 12 seconds. + +![Native KDE Open dialog over the still-rendered Blacksite editor](native-open-dialog.png) + +During the hold, Hyprland continued to report Blacksite as mapped, visible, input-capable, and native +Wayland. No application-not-responding client appeared. Cancel closed the picker without changing the +active scene, and the editor then stopped cleanly. + +## Acceptance Results + +| Area | Result | Evidence | +|------|--------|----------| +| Frame responsiveness | Pass | A blocked-dialog regression test advances eight Bevy frames before releasing the worker; live editor remained compositor-responsive during the 12-second picker hold | +| Shared coverage | Pass | Every `rfd::FileDialog` and `rfd::MessageDialog` construction is inside a `NativeDialogBroker::request` worker closure | +| Request exclusivity | Pass | Focused test rejects a second request while one is pending and permits work after one-shot completion | +| Cancellation | Pass | Live Open cancellation preserved the active scene; workflow callbacks retain explicit non-mutating cancel branches | +| Main-thread application | Pass | Paths/results cross the broker, while scene, asset, history, registry, project, and collaboration work remains in `&mut World` completions | +| Stale initiating state | Pass | Prefab/export completions reject missing initiating entities; composition validates against current state after selection | +| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed here | + +## Automated Verification + +| Command/suite | Result | +|---------------|--------| +| `cargo fmt --all -- --check` | Pass | +| `cargo check --workspace --all-targets` | Pass | +| `cargo clippy --workspace --all-targets -- -D warnings` | Pass | +| `cargo test --workspace` | Pass | +| Focused native-dialog lifecycle and frame-progress tests | 2 passed | +| `git diff --check` | Pass | diff --git a/docs/editor/evaluations/native-dialog-responsiveness/native-open-dialog.png b/docs/editor/evaluations/native-dialog-responsiveness/native-open-dialog.png new file mode 100644 index 0000000..8233c95 --- /dev/null +++ b/docs/editor/evaluations/native-dialog-responsiveness/native-open-dialog.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c5bf9a9ecf24aa4b3ac4d2ebc626379de7325d5ace4a366f21aa73387527d41 +size 1979535 diff --git a/docs/editor/native-dialogs.md b/docs/editor/native-dialogs.md new file mode 100644 index 0000000..00b542d --- /dev/null +++ b/docs/editor/native-dialogs.md @@ -0,0 +1,29 @@ +# Native Dialog Workflows + +Blacksite routes native file, folder, Save As, and unsaved-change confirmation dialogs through the +shared `NativeDialogBroker`. A native dialog may remain open without blocking Bevy's window event +loop or editor rendering. + +## Workflow Contract + +- One native dialog can be active at a time. Direct controls are disabled while pending; other + concurrent requests report that a dialog is already open. +- Native work returns only selected paths or a confirmation result. Scene, asset, project, history, + registry, and collaborative-file mutations run later on the Bevy main thread. +- Cancel is non-mutating and produces workflow-specific status. +- Entity-based operations capture the initiating entity and reject stale results. Composition + additions validate against the current composition so intervening edits are not overwritten. +- Collaborative Save As performs the same revision and destination checks after selection. +- Close or project switch never treats an opened Save As picker as a completed save. When a save + remains pending, the transition pauses and can be retried after saving. + +## Covered Surfaces + +The broker owns scene Open/Save As, import/export, prefab creation and relinking, recovery copies, +subscene selection, collaborative conflict copies, Project Browser folder selection, and dirty-scene +confirmations. New workflows must use the broker instead of calling `rfd` from an egui/Bevy system. + +See [ADR 0038](../adr/0038-non-blocking-native-dialog-broker.md). + +Live native-Wayland acceptance is recorded in the +[native-dialog responsiveness evaluation](evaluations/native-dialog-responsiveness/).