Add non-blocking native dialog broker

This commit is contained in:
Rbanh 2026-07-12 17:48:51 -04:00
parent 61be2440bd
commit d038cf3670
6 changed files with 233 additions and 11 deletions

View File

@ -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.

View File

@ -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::<Self>()
.add(EditorInfraPlugin)
.add(ProjectIoPlugin)
.add(NativeDialogPlugin)
.add(scene_schema::SceneSchemaPlugin)
.add(net_editor::NetEditorPlugin)
.add(AssetDbPlugin)

View File

@ -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()
let request = world.resource::<NativeDialogBroker>().request(
move || {
rfd::FileDialog::new()
.set_directory(directory)
.set_file_name(file_name)
.save_file()
else {
},
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 {
world
.resource_mut::<SceneIo>()
.set_status("Save conflict copy cancelled");

View File

@ -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::<ProjectLauncherUi>()
.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::<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}"));
}
}
@ -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::<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}"));
}
}
let can_create =
state.sandbox_destination.is_some() && !state.sandbox_name.trim().is_empty();

View File

@ -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;

View File

@ -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<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>);
#[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());
}
}