Add non-blocking native dialog broker
This commit is contained in:
parent
61be2440bd
commit
d038cf3670
36
.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md
Normal file
36
.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md
Normal 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.
|
||||||
@ -25,6 +25,7 @@ pub use play::state;
|
|||||||
pub use project::collaboration;
|
pub use project::collaboration;
|
||||||
pub use project::diagnostics_bundle;
|
pub use project::diagnostics_bundle;
|
||||||
pub use project::launcher;
|
pub use project::launcher;
|
||||||
|
pub use project::native_dialog;
|
||||||
pub use project::project_io;
|
pub use project::project_io;
|
||||||
pub use project::session;
|
pub use project::session;
|
||||||
pub use project::settings_ui;
|
pub use project::settings_ui;
|
||||||
@ -68,6 +69,7 @@ use operators::OperatorPlugin;
|
|||||||
use play::audio_preview::AudioPreviewPlugin;
|
use play::audio_preview::AudioPreviewPlugin;
|
||||||
use play::PlaySessionPlugin;
|
use play::PlaySessionPlugin;
|
||||||
use project::collaboration::CollaborationPlugin;
|
use project::collaboration::CollaborationPlugin;
|
||||||
|
use project::native_dialog::NativeDialogPlugin;
|
||||||
use project_io::ProjectIoPlugin;
|
use project_io::ProjectIoPlugin;
|
||||||
use render_view::RenderViewPlugin;
|
use render_view::RenderViewPlugin;
|
||||||
use scene_io::SceneIoPlugin;
|
use scene_io::SceneIoPlugin;
|
||||||
@ -90,6 +92,7 @@ impl PluginGroup for EditorPluginGroup {
|
|||||||
let group = PluginGroupBuilder::start::<Self>()
|
let group = PluginGroupBuilder::start::<Self>()
|
||||||
.add(EditorInfraPlugin)
|
.add(EditorInfraPlugin)
|
||||||
.add(ProjectIoPlugin)
|
.add(ProjectIoPlugin)
|
||||||
|
.add(NativeDialogPlugin)
|
||||||
.add(scene_schema::SceneSchemaPlugin)
|
.add(scene_schema::SceneSchemaPlugin)
|
||||||
.add(net_editor::NetEditorPlugin)
|
.add(net_editor::NetEditorPlugin)
|
||||||
.add(AssetDbPlugin)
|
.add(AssetDbPlugin)
|
||||||
|
|||||||
@ -15,6 +15,7 @@ use egui_phosphor_icons::icons;
|
|||||||
use shared::PrefabInstance;
|
use shared::PrefabInstance;
|
||||||
|
|
||||||
use crate::assets::EditorAssets;
|
use crate::assets::EditorAssets;
|
||||||
|
use crate::native_dialog::NativeDialogBroker;
|
||||||
use crate::project_io::ProjectWorkspace;
|
use crate::project_io::ProjectWorkspace;
|
||||||
use crate::scene::recovery::atomic_write_with_pre_rename;
|
use crate::scene::recovery::atomic_write_with_pre_rename;
|
||||||
use crate::scene_io::SceneIo;
|
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) {
|
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 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_directory(directory)
|
||||||
.set_file_name(file_name)
|
.set_file_name(file_name)
|
||||||
.save_file()
|
.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
|
world
|
||||||
.resource_mut::<SceneIo>()
|
.resource_mut::<SceneIo>()
|
||||||
.set_status("Save conflict copy cancelled");
|
.set_status("Save conflict copy cancelled");
|
||||||
|
|||||||
@ -13,6 +13,7 @@ use settings::{
|
|||||||
PROJECT_TEMPLATE_VERSION,
|
PROJECT_TEMPLATE_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::native_dialog::{NativeDialogBroker, NativeDialogPlugin};
|
||||||
use crate::project_io::UserPreferences;
|
use crate::project_io::UserPreferences;
|
||||||
use crate::scene::recovery::atomic_write;
|
use crate::scene::recovery::atomic_write;
|
||||||
use crate::ui::theme::{
|
use crate::ui::theme::{
|
||||||
@ -91,6 +92,7 @@ pub fn run_project_launcher() {
|
|||||||
..default()
|
..default()
|
||||||
}))
|
}))
|
||||||
.add_plugins(EguiPlugin::default())
|
.add_plugins(EguiPlugin::default())
|
||||||
|
.add_plugins(NativeDialogPlugin)
|
||||||
.init_resource::<ProjectLauncherUi>()
|
.init_resource::<ProjectLauncherUi>()
|
||||||
.add_systems(Startup, spawn_project_launcher_camera)
|
.add_systems(Startup, spawn_project_launcher_camera)
|
||||||
.add_systems(EguiPrimaryContextPass, project_launcher_ui_system);
|
.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),
|
egui::RichText::new("Select a root containing assets/project.ron").color(TEXT_DIM),
|
||||||
);
|
);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
if ui.button("Browse Existing...").clicked() {
|
let dialog_pending = world.resource::<NativeDialogBroker>().is_pending();
|
||||||
if let Some(root) = rfd::FileDialog::new().pick_folder() {
|
if ui
|
||||||
open_project_from_launcher(world, state, root);
|
.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(),
|
.truncate(),
|
||||||
);
|
);
|
||||||
if ui.button("Choose Empty Folder...").clicked() {
|
if ui
|
||||||
state.sandbox_destination = rfd::FileDialog::new().pick_folder();
|
.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 =
|
let can_create =
|
||||||
state.sandbox_destination.is_some() && !state.sandbox_name.trim().is_empty();
|
state.sandbox_destination.is_some() && !state.sandbox_name.trim().is_empty();
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
pub mod collaboration;
|
pub mod collaboration;
|
||||||
pub mod diagnostics_bundle;
|
pub mod diagnostics_bundle;
|
||||||
pub mod launcher;
|
pub mod launcher;
|
||||||
|
pub mod native_dialog;
|
||||||
pub mod project_io;
|
pub mod project_io;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod settings_ui;
|
pub mod settings_ui;
|
||||||
|
|||||||
128
crates/editor/src/project/native_dialog.rs
Normal file
128
crates/editor/src/project/native_dialog.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user