From d038cf3670d519843a94ba033e374ebcb99174f8 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sun, 12 Jul 2026 17:48:51 -0400 Subject: [PATCH] Add non-blocking native dialog broker --- ...blocking_native_dialogs_2026-07-12.plan.md | 36 +++++ crates/editor/src/lib.rs | 3 + crates/editor/src/project/collaboration.rs | 34 ++++- crates/editor/src/project/launcher.rs | 42 +++++- crates/editor/src/project/mod.rs | 1 + crates/editor/src/project/native_dialog.rs | 128 ++++++++++++++++++ 6 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 .cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md create mode 100644 crates/editor/src/project/native_dialog.rs diff --git a/.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md b/.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md new file mode 100644 index 0000000..6b743b5 --- /dev/null +++ b/.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md @@ -0,0 +1,36 @@ +# 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. diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 3a98e4b..ee184da 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -25,6 +25,7 @@ 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; @@ -68,6 +69,7 @@ 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; @@ -90,6 +92,7 @@ impl PluginGroup for EditorPluginGroup { let group = PluginGroupBuilder::start::() .add(EditorInfraPlugin) .add(ProjectIoPlugin) + .add(NativeDialogPlugin) .add(scene_schema::SceneSchemaPlugin) .add(net_editor::NetEditorPlugin) .add(AssetDbPlugin) diff --git a/crates/editor/src/project/collaboration.rs b/crates/editor/src/project/collaboration.rs index 23a5765..120fbe0 100644 --- a/crates/editor/src/project/collaboration.rs +++ b/crates/editor/src/project/collaboration.rs @@ -15,6 +15,7 @@ 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; @@ -880,13 +881,34 @@ fn resolve_conflict_reload(world: &mut World, conflict: &PendingFileConflict) { } fn resolve_conflict_save_as(world: &mut World, conflict: &PendingFileConflict) { - let directory = conflict.path.parent().unwrap_or_else(|| Path::new(".")); + let conflict = conflict.clone(); + let directory = conflict + .path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); let file_name = conflict_copy_file_name(&conflict.path); - let Some(destination) = rfd::FileDialog::new() - .set_directory(directory) - .set_file_name(file_name) - .save_file() - else { + let request = world.resource::().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::().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, +) { + let Some(destination) = destination else { world .resource_mut::() .set_status("Save conflict copy cancelled"); diff --git a/crates/editor/src/project/launcher.rs b/crates/editor/src/project/launcher.rs index 65a6bcb..c0db77b 100644 --- a/crates/editor/src/project/launcher.rs +++ b/crates/editor/src/project/launcher.rs @@ -13,6 +13,7 @@ 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::{ @@ -91,6 +92,7 @@ pub fn run_project_launcher() { ..default() })) .add_plugins(EguiPlugin::default()) + .add_plugins(NativeDialogPlugin) .init_resource::() .add_systems(Startup, spawn_project_launcher_camera) .add_systems(EguiPrimaryContextPass, project_launcher_ui_system); @@ -208,9 +210,24 @@ 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); - if ui.button("Browse Existing...").clicked() { - if let Some(root) = rfd::FileDialog::new().pick_folder() { - open_project_from_launcher(world, state, root); + let dialog_pending = world.resource::().is_pending(); + if ui + .add_enabled(!dialog_pending, egui::Button::new("Browse Existing...")) + .clicked() + { + let result = world.resource::().request( + || rfd::FileDialog::new().pick_folder(), + |world, root| { + let Some(root) = root else { + return; + }; + world.resource_scope(|world, mut state: Mut| { + open_project_from_launcher(world, &mut state, root); + }); + }, + ); + if let Err(error) = result { + state.status = Some(format!("Project browser unavailable: {error}")); } } @@ -239,8 +256,23 @@ fn launcher_actions(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLau ) .truncate(), ); - if ui.button("Choose Empty Folder...").clicked() { - state.sandbox_destination = rfd::FileDialog::new().pick_folder(); + if ui + .add_enabled(!dialog_pending, egui::Button::new("Choose Empty Folder...")) + .clicked() + { + let result = world.resource::().request( + || rfd::FileDialog::new().pick_folder(), + |world, destination| { + if let Some(destination) = destination { + world + .resource_mut::() + .sandbox_destination = Some(destination); + } + }, + ); + if let Err(error) = result { + state.status = Some(format!("Folder browser unavailable: {error}")); + } } let can_create = state.sandbox_destination.is_some() && !state.sandbox_name.trim().is_empty(); diff --git a/crates/editor/src/project/mod.rs b/crates/editor/src/project/mod.rs index 2faab20..f95e14c 100644 --- a/crates/editor/src/project/mod.rs +++ b/crates/editor/src/project/mod.rs @@ -3,6 +3,7 @@ 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; diff --git a/crates/editor/src/project/native_dialog.rs b/crates/editor/src/project/native_dialog.rs new file mode 100644 index 0000000..939220c --- /dev/null +++ b/crates/editor/src/project/native_dialog.rs @@ -0,0 +1,128 @@ +//! 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; + +#[derive(Resource)] +pub struct NativeDialogBroker { + sender: Sender, + receiver: Mutex>, + pending: Arc, +} + +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(&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::() + .add_systems(Update, drain_native_dialog_results); + } +} + +fn drain_native_dialog_results(world: &mut World) { + let callbacks: Vec<_> = { + let broker = world.resource::(); + broker + .receiver + .lock() + .expect("native dialog receiver poisoned") + .try_iter() + .collect() + }; + for callback in callbacks { + world + .resource::() + .pending + .store(0, Ordering::Release); + callback(world); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Resource, Default)] + struct ResultValue(Option); + + #[test] + fn request_is_exclusive_and_completion_runs_once() { + let mut app = App::new(); + app.add_plugins(NativeDialogPlugin) + .init_resource::(); + app.world() + .resource::() + .request( + || 42, + |world, value| world.resource_mut::().0 = Some(value), + ) + .unwrap(); + assert!(app + .world() + .resource::() + .request(|| 7, |_, _| {}) + .is_err()); + + 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(42)); + app.update(); + assert_eq!(app.world().resource::().0, Some(42)); + assert!(!app.world().resource::().is_pending()); + } +}