Compare commits

..

No commits in common. "ba59f57a5aeedb1f4ac5a34da54532d86c307849" and "61be2440bd07fa4f2f3e6f087665b287042d3ead" have entirely different histories.

16 changed files with 167 additions and 699 deletions

View File

@ -1,36 +0,0 @@
# Non-Blocking Native Dialogs
Working implementation plan for the production-readiness defect discovered during live acceptance
of collaborative file safety. Opening an `rfd` picker synchronously from an egui/Bevy system stops
window event processing long enough for Hyprland to report Blacksite as unresponsive.
## Scope
1. Add one project-level native-dialog broker with a typed intent, one active dialog, background
native work, and a main-thread result queue.
2. Convert scene open/save-as, asset import/export, prefab save/source selection, subscene selection,
conflict Save As, diagnostics export, and project-launcher folder selection to the broker.
3. Keep destructive or stateful application logic on the Bevy main thread after a path result is
received. Background work may only own the native dialog and its returned paths.
4. Show a stable pending state and reject duplicate dialog requests while a picker is active.
5. Replace blocking native dirty-scene confirmation dialogs with an editor-owned modal when the
scene-transition state machine can preserve the original action exactly.
## Contract
- The editor continues rendering and processing compositor pings while a native dialog is open.
- Cancel returns a typed empty result and does not mutate editor state.
- A result is applied once to the intent that created it; stale or duplicate results cannot run.
- Paths are validated at the same ownership boundary as before. The dialog broker does not read,
write, import, save, or canonicalize project content.
- Existing collaborative revision guards remain authoritative after Save As selection.
- Project-launcher dialogs use the same broker before the full editor plugin group is active.
## Verification
- Focused tests cover request exclusivity, cancel, one-shot result delivery, and typed intent
preservation.
- Source-only workspace formatting, check, strict Clippy, and tests run; packaged acceptance remains
deferred by project-owner direction.
- Native Wayland QA opens representative file, folder, and Save As dialogs under Hyprland and
confirms the Blacksite window remains responsive without an unresponsive-application prompt.

View File

@ -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, 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 |
| 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 |
| 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,7 +398,6 @@ 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

View File

@ -22,7 +22,6 @@ 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,
@ -752,44 +751,13 @@ fn relink_prefab_source(world: &mut World, entity: Entity) {
else {
return;
};
let directory = project_root.join("assets/prefabs");
let result = world.resource::<NativeDialogBroker>().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::<crate::scene_io::SceneIo>()
.set_status("Prefab relink cancelled");
return;
};
finish_relink_prefab_source(world, entity, project_root, path);
},
);
world
.resource_mut::<crate::scene_io::SceneIo>()
.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::<crate::scene_io::SceneIo>()
.set_status("Prefab relink failed: the initiating actor no longer exists");
let Some(path) = rfd::FileDialog::new()
.set_directory(project_root.join("assets/prefabs"))
.add_filter("Bevy prefab", &["scn.ron", "ron"])
.pick_file()
else {
return;
}
};
let Ok(path) = path.canonicalize() else {
return;
};

View File

@ -25,7 +25,6 @@ pub use play::state;
pub use project::collaboration;
pub use project::diagnostics_bundle;
pub use project::launcher;
pub use project::native_dialog;
pub use project::project_io;
pub use project::session;
pub use project::settings_ui;
@ -69,7 +68,6 @@ use operators::OperatorPlugin;
use play::audio_preview::AudioPreviewPlugin;
use play::PlaySessionPlugin;
use project::collaboration::CollaborationPlugin;
use project::native_dialog::NativeDialogPlugin;
use project_io::ProjectIoPlugin;
use render_view::RenderViewPlugin;
use scene_io::SceneIoPlugin;
@ -92,7 +90,6 @@ impl PluginGroup for EditorPluginGroup {
let group = PluginGroupBuilder::start::<Self>()
.add(EditorInfraPlugin)
.add(ProjectIoPlugin)
.add(NativeDialogPlugin)
.add(scene_schema::SceneSchemaPlugin)
.add(net_editor::NetEditorPlugin)
.add(AssetDbPlugin)

View File

@ -15,7 +15,6 @@ use egui_phosphor_icons::icons;
use shared::PrefabInstance;
use crate::assets::EditorAssets;
use crate::native_dialog::NativeDialogBroker;
use crate::project_io::ProjectWorkspace;
use crate::scene::recovery::atomic_write_with_pre_rename;
use crate::scene_io::SceneIo;
@ -881,34 +880,13 @@ fn resolve_conflict_reload(world: &mut World, conflict: &PendingFileConflict) {
}
fn resolve_conflict_save_as(world: &mut World, conflict: &PendingFileConflict) {
let conflict = conflict.clone();
let directory = conflict
.path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let directory = conflict.path.parent().unwrap_or_else(|| Path::new("."));
let file_name = conflict_copy_file_name(&conflict.path);
let request = world.resource::<NativeDialogBroker>().request(
move || {
rfd::FileDialog::new()
.set_directory(directory)
.set_file_name(file_name)
.save_file()
},
move |world, destination| finish_conflict_save_as(world, conflict, destination),
);
world.resource_mut::<SceneIo>().set_status(match request {
Ok(()) => "Choose a destination for the conflict copy".into(),
Err(error) => format!("Save As unavailable: {error}"),
});
}
fn finish_conflict_save_as(
world: &mut World,
conflict: PendingFileConflict,
destination: Option<PathBuf>,
) {
let Some(destination) = destination else {
let Some(destination) = rfd::FileDialog::new()
.set_directory(directory)
.set_file_name(file_name)
.save_file()
else {
world
.resource_mut::<SceneIo>()
.set_status("Save conflict copy cancelled");

View File

@ -13,7 +13,6 @@ use settings::{
PROJECT_TEMPLATE_VERSION,
};
use crate::native_dialog::{NativeDialogBroker, NativeDialogPlugin};
use crate::project_io::UserPreferences;
use crate::scene::recovery::atomic_write;
use crate::ui::theme::{
@ -92,7 +91,6 @@ pub fn run_project_launcher() {
..default()
}))
.add_plugins(EguiPlugin::default())
.add_plugins(NativeDialogPlugin)
.init_resource::<ProjectLauncherUi>()
.add_systems(Startup, spawn_project_launcher_camera)
.add_systems(EguiPrimaryContextPass, project_launcher_ui_system);
@ -210,24 +208,9 @@ fn launcher_actions(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLau
egui::RichText::new("Select a root containing assets/project.ron").color(TEXT_DIM),
);
ui.add_space(8.0);
let dialog_pending = world.resource::<NativeDialogBroker>().is_pending();
if ui
.add_enabled(!dialog_pending, egui::Button::new("Browse Existing..."))
.clicked()
{
let result = world.resource::<NativeDialogBroker>().request(
|| rfd::FileDialog::new().pick_folder(),
|world, root| {
let Some(root) = root else {
return;
};
world.resource_scope(|world, mut state: Mut<ProjectLauncherUi>| {
open_project_from_launcher(world, &mut state, root);
});
},
);
if let Err(error) = result {
state.status = Some(format!("Project browser unavailable: {error}"));
if ui.button("Browse Existing...").clicked() {
if let Some(root) = rfd::FileDialog::new().pick_folder() {
open_project_from_launcher(world, state, root);
}
}
@ -256,23 +239,8 @@ fn launcher_actions(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLau
)
.truncate(),
);
if ui
.add_enabled(!dialog_pending, egui::Button::new("Choose Empty Folder..."))
.clicked()
{
let result = world.resource::<NativeDialogBroker>().request(
|| rfd::FileDialog::new().pick_folder(),
|world, destination| {
if let Some(destination) = destination {
world
.resource_mut::<ProjectLauncherUi>()
.sandbox_destination = Some(destination);
}
},
);
if let Err(error) = result {
state.status = Some(format!("Folder browser unavailable: {error}"));
}
if ui.button("Choose Empty Folder...").clicked() {
state.sandbox_destination = rfd::FileDialog::new().pick_folder();
}
let can_create =
state.sandbox_destination.is_some() && !state.sandbox_name.trim().is_empty();

View File

@ -3,7 +3,6 @@
pub mod collaboration;
pub mod diagnostics_bundle;
pub mod launcher;
pub mod native_dialog;
pub mod project_io;
pub mod session;
pub mod settings_ui;

View File

@ -1,171 +0,0 @@
//! Non-blocking bridge between native dialogs and main-thread editor workflows.
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc, Mutex,
};
use bevy::prelude::*;
type MainThreadCompletion = Box<dyn FnOnce(&mut World) + Send + 'static>;
#[derive(Resource)]
pub struct NativeDialogBroker {
sender: Sender<MainThreadCompletion>,
receiver: Mutex<Receiver<MainThreadCompletion>>,
pending: Arc<AtomicUsize>,
}
impl Default for NativeDialogBroker {
fn default() -> Self {
let (sender, receiver) = mpsc::channel();
Self {
sender,
receiver: Mutex::new(receiver),
pending: Arc::new(AtomicUsize::new(0)),
}
}
}
impl NativeDialogBroker {
pub fn is_pending(&self) -> bool {
self.pending.load(Ordering::Acquire) > 0
}
pub fn request<T, D, C>(&self, dialog: D, completion: C) -> Result<(), &'static str>
where
T: Send + 'static,
D: FnOnce() -> T + Send + 'static,
C: FnOnce(&mut World, T) + Send + 'static,
{
if self
.pending
.compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return Err("another native dialog is already open");
}
let sender = self.sender.clone();
let pending = Arc::clone(&self.pending);
std::thread::spawn(move || {
let result = dialog();
let callback: MainThreadCompletion = Box::new(move |world| completion(world, result));
if sender.send(callback).is_err() {
pending.store(0, Ordering::Release);
}
});
Ok(())
}
}
pub struct NativeDialogPlugin;
impl Plugin for NativeDialogPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<NativeDialogBroker>()
.add_systems(Update, drain_native_dialog_results);
}
}
fn drain_native_dialog_results(world: &mut World) {
let callbacks: Vec<_> = {
let broker = world.resource::<NativeDialogBroker>();
broker
.receiver
.lock()
.expect("native dialog receiver poisoned")
.try_iter()
.collect()
};
for callback in callbacks {
world
.resource::<NativeDialogBroker>()
.pending
.store(0, Ordering::Release);
callback(world);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Resource, Default)]
struct ResultValue(Option<u32>);
#[derive(Resource, Default)]
struct FrameCount(u32);
#[test]
fn request_is_exclusive_and_completion_runs_once() {
let mut app = App::new();
app.add_plugins(NativeDialogPlugin)
.init_resource::<ResultValue>();
app.world()
.resource::<NativeDialogBroker>()
.request(
|| 42,
|world, value| world.resource_mut::<ResultValue>().0 = Some(value),
)
.unwrap();
assert!(app
.world()
.resource::<NativeDialogBroker>()
.request(|| 7, |_, _| {})
.is_err());
for _ in 0..100 {
app.update();
if app.world().resource::<ResultValue>().0.is_some() {
break;
}
std::thread::yield_now();
}
assert_eq!(app.world().resource::<ResultValue>().0, Some(42));
app.update();
assert_eq!(app.world().resource::<ResultValue>().0, Some(42));
assert!(!app.world().resource::<NativeDialogBroker>().is_pending());
}
#[test]
fn blocked_dialog_worker_does_not_block_app_frames() {
let mut app = App::new();
app.add_plugins(NativeDialogPlugin)
.init_resource::<ResultValue>()
.init_resource::<FrameCount>()
.add_systems(Update, |mut frames: ResMut<FrameCount>| frames.0 += 1);
let (release_sender, release_receiver) = mpsc::channel();
app.world()
.resource::<NativeDialogBroker>()
.request(
move || {
release_receiver
.recv()
.expect("test release sender dropped")
},
|world, value| world.resource_mut::<ResultValue>().0 = Some(value),
)
.unwrap();
for _ in 0..8 {
app.update();
}
assert_eq!(app.world().resource::<FrameCount>().0, 8);
assert_eq!(app.world().resource::<ResultValue>().0, None);
assert!(app.world().resource::<NativeDialogBroker>().is_pending());
release_sender.send(73).unwrap();
for _ in 0..100 {
app.update();
if app.world().resource::<ResultValue>().0.is_some() {
break;
}
std::thread::yield_now();
}
assert_eq!(app.world().resource::<ResultValue>().0, Some(73));
assert!(app.world().resource::<FrameCount>().0 > 8);
assert!(!app.world().resource::<NativeDialogBroker>().is_pending());
}
}

View File

@ -23,7 +23,6 @@ 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,
@ -83,6 +82,8 @@ pub struct SceneIo {
pub request: Option<SceneIoRequest>,
pub status: String,
pub dirty: bool,
/// When set, the next I/O request needs unsaved-changes confirmation.
pub pending_request: Option<SceneIoRequest>,
/// Newest recovery snapshot that is newer than the active authored scene.
pub recovery_snapshot: Option<PathBuf>,
/// Bounded in-session audit trail for scene persistence and recovery operations.
@ -118,6 +119,7 @@ impl Default for SceneIo {
request: None,
status: "Ready".to_string(),
dirty: false,
pending_request: None,
recovery_snapshot: None,
events: VecDeque::new(),
tabs: vec![SceneTab {
@ -289,15 +291,39 @@ fn load_startup_scene(world: &mut World) {
}
fn process_scene_io_requests(world: &mut World) {
let Some(request) = world.resource_mut::<SceneIo>().request.take() else {
return;
let request = {
let mut io = world.resource_mut::<SceneIo>();
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,
}
}
};
if matches!(request, SceneIoRequest::SwitchProject)
&& world.resource::<SceneIo>().has_unsaved_tabs()
{
request_switch_project_confirmation(world);
return;
if world.resource::<SceneIo>().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::<SceneIo>().set_status(save_status);
return;
}
}
DirtyConfirm::Discard => {}
DirtyConfirm::Cancel => {
world
.resource_mut::<SceneIo>()
.set_status("Operation cancelled");
return;
}
}
}
let status = match request {
@ -322,6 +348,16 @@ fn process_scene_io_requests(world: &mut World) {
world.resource_mut::<SceneIo>().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(()) => {
@ -332,41 +368,19 @@ fn switch_project(world: &mut World) -> String {
}
}
fn request_switch_project_confirmation(world: &mut World) {
let result = world.resource::<NativeDialogBroker>().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::<SceneIo>().set_status(status);
} else {
world.resource_mut::<SceneIo>().set_status(format!(
"Project switch paused until every scene is saved: {status}"
));
}
}
rfd::MessageDialogResult::No => {
let status = switch_project(world);
world.resource_mut::<SceneIo>().set_status(status);
}
_ => world
.resource_mut::<SceneIo>()
.set_status("Project switch cancelled"),
},
);
world.resource_mut::<SceneIo>().set_status(match result {
Ok(()) => "Waiting for unsaved-scene confirmation".into(),
Err(error) => format!("Project switch unavailable: {error}"),
});
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 open_recent(world: &mut World, index: usize) -> String {
@ -519,14 +533,18 @@ fn close_scene_tab(world: &mut World, index: usize) -> String {
}
if world.resource::<SceneIo>().dirty {
request_close_scene_confirmation(world);
return "Waiting for close-scene confirmation".to_string();
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(),
}
}
finish_close_scene_tab(world)
}
fn finish_close_scene_tab(world: &mut World) -> String {
if world.resource::<SceneIo>().tabs.len() == 1 {
clear_scene_world(world);
world.insert_resource(SceneComposition {
@ -600,36 +618,18 @@ fn reload_active_composition(world: &mut World) -> String {
"Reloaded active scene composition".to_string()
}
fn request_close_scene_confirmation(world: &mut World) {
let result = world.resource::<NativeDialogBroker>().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::<SceneIo>().set_status(status);
},
);
if let Err(error) = result {
world
.resource_mut::<SceneIo>()
.set_status(format!("Close scene unavailable: {error}"));
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,
}
}
@ -694,31 +694,12 @@ fn save_selection_as_prefab(world: &mut World) -> String {
return "Select level objects to save as prefab".to_string();
}
let request = world.resource::<NativeDialogBroker>().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::<SceneIo>().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<Entity>,
path: Option<PathBuf>,
) -> String {
let Some(path) = path else {
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 {
return "Save prefab cancelled".to_string();
};
let expected = match FileSnapshot::capture(&path) {
@ -878,27 +859,12 @@ fn save_active_or_prompt(world: &mut World) -> String {
}
fn save_with_dialog(world: &mut World) -> String {
let request = world.resource::<NativeDialogBroker>().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::<SceneIo>().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<PathBuf>) -> String {
let Some(path) = path else {
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 {
return "Save cancelled".to_string();
};
let expected = match FileSnapshot::capture(&path) {
@ -919,51 +885,31 @@ fn finish_save_with_dialog(world: &mut World, path: Option<PathBuf>) -> String {
}
fn open_with_dialog(world: &mut World) -> String {
let request = world.resource::<NativeDialogBroker>().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::<SceneIo>().set_status(status);
},
);
match request {
Ok(()) => "Choose a scene to open".to_string(),
Err(error) => format!("Open unavailable: {error}"),
}
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)
}
fn import_with_dialog(world: &mut World) -> String {
let request = world.resource::<NativeDialogBroker>().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::<EditorAssets>().refresh();
format!("Imported {count} asset(s)")
}
Err(err) => format!("Import failed: {err}"),
},
};
world.resource_mut::<SceneIo>().set_status(status);
},
);
match request {
Ok(()) => "Choose assets to import".to_string(),
Err(error) => format!("Import unavailable: {error}"),
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::<EditorAssets>().refresh();
format!("Imported {count} asset(s)")
}
Err(err) => format!("Import failed: {err}"),
}
}
@ -975,32 +921,14 @@ fn export_selection_with_dialog(world: &mut World) -> String {
return "Selected entity is not an authored level object".to_string();
}
let request = world.resource::<NativeDialogBroker>().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::<SceneIo>().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<PathBuf>) -> String {
let Some(path) = path else {
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 {
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}"),
@ -1679,32 +1607,12 @@ fn save_recovery_copy_with_dialog(world: &mut World) -> String {
|| "recovered_scene.scn.ron".to_string(),
|stem| format!("{stem}.recovered.scn.ron"),
);
let directory = directory.to_path_buf();
let request = world.resource::<NativeDialogBroker>().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::<SceneIo>().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<PathBuf>,
) -> String {
let Some(destination) = destination else {
let Some(destination) = rfd::FileDialog::new()
.set_directory(directory)
.add_filter("Bevy scene", &["scn.ron", "ron"])
.set_file_name(file_name)
.save_file()
else {
return "Save recovery copy cancelled".to_string();
};
let expected = match FileSnapshot::capture(&destination) {

View File

@ -8,7 +8,6 @@ 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;
@ -196,47 +195,19 @@ fn composition_menu(
ui.separator();
if ui
.add_enabled(
editing && !world.resource::<NativeDialogBroker>().is_pending(),
egui::Button::new("Add Subscene..."),
)
.add_enabled(editing, egui::Button::new("Add Subscene..."))
.clicked()
{
let project_root =
PathBuf::from(&world.resource::<crate::project_io::ProjectWorkspace>().root);
let directory = project_root.join("assets/levels");
let result = world.resource::<NativeDialogBroker>().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::<SceneIo>()
.set_status("Add subscene cancelled");
return;
};
let current = world.resource::<SceneComposition>().clone();
match validate_subscene_choice(&project_root, &current, path) {
Ok(reference) => {
let mut updated = current;
updated.subscenes.push(reference);
crate::history::set_scene_composition_with_history(world, updated);
world.resource_mut::<SceneIo>().set_status("Subscene added");
}
Err(error) => world
.resource_mut::<SceneIo>()
.set_status(format!("Add subscene failed: {error}")),
}
},
);
world.resource_mut::<SceneIo>().set_status(match result {
Ok(()) => "Choose a scene to compose".into(),
Err(error) => format!("Add subscene unavailable: {error}"),
});
match choose_subscene(world, &composition) {
Ok(Some(reference)) => {
composition.subscenes.push(reference);
changed = true;
}
Ok(None) => {}
Err(error) => world
.resource_mut::<SceneIo>()
.set_status(format!("Add subscene failed: {error}")),
}
}
if changed {
@ -247,14 +218,20 @@ fn composition_menu(
}
}
fn validate_subscene_choice(
project_root: &Path,
fn choose_subscene(
world: &World,
composition: &SceneComposition,
path: PathBuf,
) -> Result<SubsceneReference, String> {
let project_root = project_root
) -> Result<Option<SubsceneReference>, String> {
let project_root = PathBuf::from(&world.resource::<crate::project_io::ProjectWorkspace>().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()))?;
@ -274,12 +251,12 @@ fn validate_subscene_choice(
{
return Err(format!("{relative} is already composed"));
}
Ok(SubsceneReference {
Ok(Some(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) {

View File

@ -52,7 +52,6 @@ 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
@ -78,7 +77,6 @@ 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 |

View File

@ -1,40 +0,0 @@
# 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.

View File

@ -24,11 +24,9 @@ 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 |
@ -50,7 +48,6 @@ 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 |

View File

@ -1,42 +0,0 @@
# 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 |

View File

@ -1,29 +0,0 @@
# 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/).