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::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)
|
||||
|
||||
@ -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::<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 {
|
||||
world
|
||||
.resource_mut::<SceneIo>()
|
||||
.set_status("Save conflict copy cancelled");
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
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