Guard editor shutdown and track clean savepoints
This commit is contained in:
parent
62b538999d
commit
4b33f32357
62
.cursor/plans/guarded_shutdown_savepoints_2026-07-13.plan.md
Normal file
62
.cursor/plans/guarded_shutdown_savepoints_2026-07-13.plan.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Guarded Shutdown And History Savepoints
|
||||||
|
|
||||||
|
Date: 2026-07-13
|
||||||
|
Issue: BS-PR-709 / Gitea #55
|
||||||
|
Milestone: M7 - Production Readiness
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Route every editor-exit surface through one non-blocking dirty-document decision and make scene
|
||||||
|
dirtiness follow the authored undo timeline. Closing the native window, choosing File > Quit, and
|
||||||
|
requesting an editor exit programmatically must never silently discard dirty scene tabs.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- The editor disables Bevy's automatic close-and-exit systems and owns primary-window close
|
||||||
|
requests. A guarded-shutdown resource is the single authority that may emit `AppExit`.
|
||||||
|
- The final dirty-state recheck and `AppExit` authorization run in a dedicated schedule after
|
||||||
|
Bevy's `Last`; session clean-marker persistence runs after that finalizer.
|
||||||
|
- Native window close, File > Quit, and internal editor quit requests enqueue the same shutdown
|
||||||
|
intent. Repeated requests while confirmation or Save As is pending are coalesced.
|
||||||
|
- Clean sessions exit immediately. Dirty sessions use the shared `NativeDialogBroker` and a single
|
||||||
|
Save All / Discard / Cancel decision. Cancel preserves all tabs and live editor state; Discard
|
||||||
|
exits without rewriting authored files; Save All exits only after every dirty tab is saved.
|
||||||
|
- Untitled tabs and other asynchronous Save As work keep shutdown pending until their dialog result
|
||||||
|
is applied on the main thread. Cancellation or any failed write returns the editor to an idle,
|
||||||
|
dirty, recoverable state and does not emit `AppExit`.
|
||||||
|
- Each scene tab retains a canonical authored-content checkpoint established only by a successful
|
||||||
|
load or save. The checkpoint is keyed by stable actor identity, canonical component order, stable
|
||||||
|
parent identity, and scene composition rather than transient Bevy entity numbers.
|
||||||
|
- Push marks the active document dirty. Undo and redo serialize the authoritative authored
|
||||||
|
projection and compare it with that tab's checkpoint, so saving at nonzero history depth,
|
||||||
|
returning to that point, branching, tab switches, and direct non-history mutations remain exact.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
1. Add per-tab canonical clean checkpoints to scene I/O and reconcile `SceneIo::dirty` after push,
|
||||||
|
undo, redo, save, load, and document switches without persisting Entity-ID-based history stacks.
|
||||||
|
2. Add focused history tests for undo-to-clean, redo-away-from-clean, save at nonzero depth,
|
||||||
|
branching before/after the clean point, and tab isolation.
|
||||||
|
3. Add a guarded-shutdown plugin/resource that intercepts `WindowCloseRequested`, accepts menu and
|
||||||
|
programmatic requests, coordinates non-blocking confirmation/save completion, and emits the only
|
||||||
|
final `AppExit` for the full editor.
|
||||||
|
4. Make Save All report complete, pending, cancelled, or failed explicitly. Resume pending shutdown
|
||||||
|
after untitled Save As completion and preserve the originating tab plus dirty state on failure.
|
||||||
|
5. Add File > Quit and route Switch Project's exit half through the same guard without spawning a
|
||||||
|
replacement process until the dirty-document decision succeeds.
|
||||||
|
6. Update ADR 0023, native-dialog/session-recovery documentation, editor architecture guidance,
|
||||||
|
root README controls/checklist, and production-readiness evidence.
|
||||||
|
7. Run formatting, strict Clippy, workspace tests, and focused headless state-machine tests. Keep
|
||||||
|
packaged tests deferred by project-owner direction.
|
||||||
|
8. Launch the exact editor commit under Hyprland. Exercise Cancel, Discard, Save All, clean close,
|
||||||
|
File > Quit, and the compositor close button; verify the editor remains responsive during native
|
||||||
|
dialogs, no state is lost on cancel/failure, and no process or warning remains after exit.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Native close, File > Quit, and programmatic editor exit share one guarded implementation.
|
||||||
|
- One or many dirty tabs cannot be lost without explicit Discard; failed or cancelled saves do not
|
||||||
|
exit and preserve the session.
|
||||||
|
- Undoing exactly to the saved state clears the dirty marker; redo or a divergent edit restores it.
|
||||||
|
- Saving at nonzero history depth establishes a new clean point without deleting useful history.
|
||||||
|
- Headless state-machine tests and exact-commit native Linux QA pass with clean logs.
|
||||||
11
README.md
11
README.md
@ -57,8 +57,9 @@ cargo run -p editor --bin project_launcher --features dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
The installed **Blacksite Editor** desktop entry also exposes **Open Project Browser** from its
|
The installed **Blacksite Editor** desktop entry also exposes **Open Project Browser** from its
|
||||||
desktop action menu. In the editor, **File > Switch Project...** performs a clean shutdown and
|
desktop action menu. In the editor, **File > Switch Project...** uses the guarded Save All / Discard /
|
||||||
opens the same browser; choosing a project starts a fresh editor process with that root.
|
Cancel shutdown path before opening the same browser; choosing a project starts a fresh editor
|
||||||
|
process with that root.
|
||||||
|
|
||||||
### Hot reload (gameplay iteration)
|
### Hot reload (gameplay iteration)
|
||||||
|
|
||||||
@ -136,7 +137,7 @@ deep-stale variants.
|
|||||||
| Click empty viewport / `Esc` | Deselect |
|
| Click empty viewport / `Esc` | Deselect |
|
||||||
| `Delete` / `Backspace` | Delete selection |
|
| `Delete` / `Backspace` | Delete selection |
|
||||||
| `Ctrl+D` | Duplicate selection |
|
| `Ctrl+D` | Duplicate selection |
|
||||||
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo |
|
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo; returning exactly to the last loaded or saved authored state clears that scene tab's dirty marker |
|
||||||
| `F2` in Hierarchy | Rename selection |
|
| `F2` in Hierarchy | Rename selection |
|
||||||
| `W` / `E` / `R` | Translate / rotate / scale gizmo; multi-selection uses one grouped gizmo and undo step |
|
| `W` / `E` / `R` | Translate / rotate / scale gizmo; multi-selection uses one grouped gizmo and undo step |
|
||||||
| `X` | Toggle world/local gizmo orientation |
|
| `X` | Toggle world/local gizmo orientation |
|
||||||
@ -165,7 +166,8 @@ deep-stale variants.
|
|||||||
| Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root |
|
| 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 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 |
|
| 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, **Open Sample** for the five-area regression pack, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes |
|
| File menu | New, non-blocking native Open/Save As, **Open Sample** for the five-area regression pack, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes, Switch Project, and Quit |
|
||||||
|
| Window close / **File > Quit** | Clean sessions exit immediately; dirty scene tabs use one non-blocking **Save All / Discard / Cancel** decision, and cancel or failed saves keep the editor open |
|
||||||
| 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 |
|
| 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 |
|
| 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 |
|
| Main toolbar, right side | Switch or close independent scene tabs, create an untitled tab, and manage loaded/locked composed subscenes |
|
||||||
@ -410,6 +412,7 @@ crates/
|
|||||||
- [x] Transactional physics placement with real Avian gravity/colliders, paused Edit-mode physics, prerequisite diagnostics, isolated non-selected bodies, exact cancel, and grouped transform undo ([ADR 0041](docs/adr/0041-transactional-editor-physics-placement.md), [workflow guide](docs/editor/physics-placement.md), [Gitea #25](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/25))
|
- [x] Transactional physics placement with real Avian gravity/colliders, paused Edit-mode physics, prerequisite diagnostics, isolated non-selected bodies, exact cancel, and grouped transform undo ([ADR 0041](docs/adr/0041-transactional-editor-physics-placement.md), [workflow guide](docs/editor/physics-placement.md), [Gitea #25](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/25))
|
||||||
- [x] Collider authoring health shared by the inspector, Collider viewport, Diagnostics panel, and physics placement, with scaled shape overlays, cooked mesh bounds, missing/stale/invalid/oversized findings, and undoable dimension-preserving shape switching ([collider guide](docs/editor/collider-authoring.md), [Gitea #26](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/26))
|
- [x] Collider authoring health shared by the inspector, Collider viewport, Diagnostics panel, and physics placement, with scaled shape overlays, cooked mesh bounds, missing/stale/invalid/oversized findings, and undoable dimension-preserving shape switching ([collider guide](docs/editor/collider-authoring.md), [Gitea #26](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/26))
|
||||||
- [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] 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] Guarded native/menu/programmatic editor shutdown with asynchronous multi-tab Save All / Discard / Cancel and canonical per-tab clean checkpoints for exact undo/redo dirtiness ([ADR 0042](docs/adr/0042-guarded-editor-shutdown-and-document-savepoints.md), [Gitea #55](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/55))
|
||||||
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
|
- [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] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
|
||||||
- [x] Unified viewport render-to-texture target + Play session bootstrap
|
- [x] Unified viewport render-to-texture target + Play session bootstrap
|
||||||
|
|||||||
@ -1631,7 +1631,7 @@ pub fn apply_command_undo(world: &mut World) {
|
|||||||
undo_command(world, &mut command);
|
undo_command(world, &mut command);
|
||||||
world.resource_mut::<EditorHistory>().push_redo(command);
|
world.resource_mut::<EditorHistory>().push_redo(command);
|
||||||
world.resource_mut::<EditorHistory>().set_redo_status(label);
|
world.resource_mut::<EditorHistory>().set_redo_status(label);
|
||||||
mark_dirty(world);
|
crate::scene_io::reconcile_active_dirty_with_checkpoint(world);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn apply_command_redo(world: &mut World) {
|
pub fn apply_command_redo(world: &mut World) {
|
||||||
@ -1652,7 +1652,7 @@ pub fn apply_command_redo(world: &mut World) {
|
|||||||
redo_command(world, &mut command);
|
redo_command(world, &mut command);
|
||||||
world.resource_mut::<EditorHistory>().push_undo(command);
|
world.resource_mut::<EditorHistory>().push_undo(command);
|
||||||
world.resource_mut::<EditorHistory>().set_undo_status(label);
|
world.resource_mut::<EditorHistory>().set_undo_status(label);
|
||||||
mark_dirty(world);
|
crate::scene_io::reconcile_active_dirty_with_checkpoint(world);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_prefab_source_history(
|
fn prepare_prefab_source_history(
|
||||||
|
|||||||
@ -30,6 +30,7 @@ pub use project::project_io;
|
|||||||
pub use project::samples;
|
pub use project::samples;
|
||||||
pub use project::session;
|
pub use project::session;
|
||||||
pub use project::settings_ui;
|
pub use project::settings_ui;
|
||||||
|
pub use project::shutdown;
|
||||||
pub use scene::scene_io;
|
pub use scene::scene_io;
|
||||||
pub use scene::scene_schema;
|
pub use scene::scene_schema;
|
||||||
pub use scene::scene_view;
|
pub use scene::scene_view;
|
||||||
@ -76,6 +77,7 @@ use play::PlaySessionPlugin;
|
|||||||
use project::collaboration::CollaborationPlugin;
|
use project::collaboration::CollaborationPlugin;
|
||||||
use project::native_dialog::NativeDialogPlugin;
|
use project::native_dialog::NativeDialogPlugin;
|
||||||
use project::samples::SampleCatalogPlugin;
|
use project::samples::SampleCatalogPlugin;
|
||||||
|
use project::shutdown::ShutdownPlugin;
|
||||||
use project_io::ProjectIoPlugin;
|
use project_io::ProjectIoPlugin;
|
||||||
use render_view::RenderViewPlugin;
|
use render_view::RenderViewPlugin;
|
||||||
use scene_io::SceneIoPlugin;
|
use scene_io::SceneIoPlugin;
|
||||||
@ -102,6 +104,7 @@ impl PluginGroup for EditorPluginGroup {
|
|||||||
.add(ProjectIoPlugin)
|
.add(ProjectIoPlugin)
|
||||||
.add(SampleCatalogPlugin)
|
.add(SampleCatalogPlugin)
|
||||||
.add(NativeDialogPlugin)
|
.add(NativeDialogPlugin)
|
||||||
|
.add(ShutdownPlugin)
|
||||||
.add(scene_schema::SceneSchemaPlugin)
|
.add(scene_schema::SceneSchemaPlugin)
|
||||||
.add(net_editor::NetEditorPlugin)
|
.add(net_editor::NetEditorPlugin)
|
||||||
.add(AssetDbPlugin)
|
.add(AssetDbPlugin)
|
||||||
@ -143,7 +146,7 @@ impl PluginGroup for EditorPluginGroup {
|
|||||||
|
|
||||||
/// Shared Bevy app wiring for the in-process editor (game sim + egui shell).
|
/// Shared Bevy app wiring for the in-process editor (game sim + egui shell).
|
||||||
pub fn configure_editor_app(app: &mut App) {
|
pub fn configure_editor_app(app: &mut App) {
|
||||||
app.add_plugins(launch::default_plugins("Bevy FPS Editor"))
|
app.add_plugins(launch::editor_plugins("Bevy FPS Editor"))
|
||||||
.insert_resource(GameInputEnabled(false))
|
.insert_resource(GameInputEnabled(false))
|
||||||
.insert_resource(GameInputFocused(false))
|
.insert_resource(GameInputFocused(false))
|
||||||
.insert_resource(SimEnabled(false))
|
.insert_resource(SimEnabled(false))
|
||||||
|
|||||||
@ -8,3 +8,4 @@ pub mod project_io;
|
|||||||
pub mod samples;
|
pub mod samples;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod settings_ui;
|
pub mod settings_ui;
|
||||||
|
pub mod shutdown;
|
||||||
|
|||||||
@ -11,6 +11,7 @@ use crate::project_io::{ProjectWorkspace, UserPreferences};
|
|||||||
use crate::scene::recovery::{atomic_write, default_state_root};
|
use crate::scene::recovery::{atomic_write, default_state_root};
|
||||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||||
use crate::settings_ui::ProjectSettingsPanel;
|
use crate::settings_ui::ProjectSettingsPanel;
|
||||||
|
use crate::shutdown::ShutdownCoordinator;
|
||||||
use crate::ui::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
use crate::ui::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
||||||
use crate::viewport::rendering_diagnostics::RenderingDiagnosticsPanel;
|
use crate::viewport::rendering_diagnostics::RenderingDiagnosticsPanel;
|
||||||
use crate::viewport::CameraBookmarks;
|
use crate::viewport::CameraBookmarks;
|
||||||
@ -109,8 +110,8 @@ impl Plugin for EditorSessionPlugin {
|
|||||||
.add_systems(Startup, initialize_session)
|
.add_systems(Startup, initialize_session)
|
||||||
.add_systems(Update, persist_running_session)
|
.add_systems(Update, persist_running_session)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Last,
|
crate::shutdown::EditorShutdownFinalize,
|
||||||
persist_clean_session_on_exit.after(bevy::window::ExitSystems),
|
persist_clean_session_on_exit.after(crate::shutdown::finalize_authorized_shutdown),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -311,6 +312,7 @@ fn persist_running_session(world: &mut World) {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn persist_clean_session_on_exit(
|
fn persist_clean_session_on_exit(
|
||||||
mut exits: MessageReader<AppExit>,
|
mut exits: MessageReader<AppExit>,
|
||||||
|
shutdown: Option<Res<ShutdownCoordinator>>,
|
||||||
scene_io: Res<SceneIo>,
|
scene_io: Res<SceneIo>,
|
||||||
workspace: Res<ProjectWorkspace>,
|
workspace: Res<ProjectWorkspace>,
|
||||||
prefs: Res<UserPreferences>,
|
prefs: Res<UserPreferences>,
|
||||||
@ -323,6 +325,10 @@ fn persist_clean_session_on_exit(
|
|||||||
if exits.read().next().is_none() {
|
if exits.read().next().is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if !shutdown.is_some_and(|shutdown| shutdown.authorized_exit_sent()) {
|
||||||
|
warn!("Ignoring an unauthorized AppExit for clean-session persistence");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(path) = session_path() else {
|
let Some(path) = session_path() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
752
crates/editor/src/project/shutdown.rs
Normal file
752
crates/editor/src/project/shutdown.rs
Normal file
@ -0,0 +1,752 @@
|
|||||||
|
//! Guarded editor shutdown and the handoff to scene persistence.
|
||||||
|
|
||||||
|
use bevy::app::{AppExit, MainScheduleOrder};
|
||||||
|
use bevy::ecs::schedule::ScheduleLabel;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::{PrimaryWindow, WindowCloseRequested};
|
||||||
|
|
||||||
|
use crate::native_dialog::NativeDialogBroker;
|
||||||
|
use crate::scene_io::SceneIo;
|
||||||
|
|
||||||
|
const SAVE_ALL_LABEL: &str = "Save All";
|
||||||
|
const DISCARD_LABEL: &str = "Discard";
|
||||||
|
const CANCEL_LABEL: &str = "Cancel";
|
||||||
|
|
||||||
|
/// The user or editor surface that initiated a guarded shutdown.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ShutdownSource {
|
||||||
|
NativeWindow,
|
||||||
|
FileMenu,
|
||||||
|
Programmatic,
|
||||||
|
SwitchProject,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observable state of the single editor shutdown flow.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum ShutdownIntent {
|
||||||
|
#[default]
|
||||||
|
Idle,
|
||||||
|
WaitingForBroker {
|
||||||
|
source: ShutdownSource,
|
||||||
|
},
|
||||||
|
Confirming {
|
||||||
|
source: ShutdownSource,
|
||||||
|
},
|
||||||
|
Saving {
|
||||||
|
source: ShutdownSource,
|
||||||
|
},
|
||||||
|
Authorized {
|
||||||
|
source: ShutdownSource,
|
||||||
|
require_clean_scenes: bool,
|
||||||
|
},
|
||||||
|
ExitSent {
|
||||||
|
source: ShutdownSource,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShutdownIntent {
|
||||||
|
pub fn source(self) -> Option<ShutdownSource> {
|
||||||
|
match self {
|
||||||
|
Self::Idle => None,
|
||||||
|
Self::WaitingForBroker { source }
|
||||||
|
| Self::Confirming { source }
|
||||||
|
| Self::Saving { source }
|
||||||
|
| Self::Authorized { source, .. }
|
||||||
|
| Self::ExitSent { source } => Some(source),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coordinates all editor exit requests so only an authorized flow emits [`AppExit`].
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct ShutdownCoordinator {
|
||||||
|
intent: ShutdownIntent,
|
||||||
|
coalesced_requests: u32,
|
||||||
|
last_blocker: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShutdownCoordinator {
|
||||||
|
pub fn intent(&self) -> ShutdownIntent {
|
||||||
|
self.intent
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn coalesced_requests(&self) -> u32 {
|
||||||
|
self.coalesced_requests
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_blocker(&self) -> Option<&str> {
|
||||||
|
self.last_blocker.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authorized_exit_sent(&self) -> bool {
|
||||||
|
matches!(self.intent, ShutdownIntent::ExitSent { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a shutdown if no shutdown flow is already active.
|
||||||
|
///
|
||||||
|
/// Repeated window, menu, or programmatic requests are coalesced until the
|
||||||
|
/// current flow is cancelled, fails to save, or sends the authorized exit.
|
||||||
|
pub fn request(&mut self, source: ShutdownSource) -> bool {
|
||||||
|
if self.intent != ShutdownIntent::Idle {
|
||||||
|
self.coalesced_requests = self.coalesced_requests.saturating_add(1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.intent = ShutdownIntent::WaitingForBroker { source };
|
||||||
|
self.coalesced_requests = 0;
|
||||||
|
self.last_blocker = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn set_intent_for_test(&mut self, intent: ShutdownIntent) {
|
||||||
|
self.intent = intent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emitted once when the guarded dialog chooses `Save All`.
|
||||||
|
///
|
||||||
|
/// Scene persistence owns the actual save sequence and must finish it with
|
||||||
|
/// [`complete_shutdown_save`].
|
||||||
|
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ShutdownSaveAllRequested {
|
||||||
|
pub source: ShutdownSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ShutdownSaveOutcome {
|
||||||
|
Saved,
|
||||||
|
Cancelled,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ShutdownDecision {
|
||||||
|
SaveAll,
|
||||||
|
Discard,
|
||||||
|
Cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs after Bevy's `Last` schedule so no editor authoring system can mutate
|
||||||
|
/// a document after the final dirty-state check in the same frame.
|
||||||
|
#[derive(ScheduleLabel, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct EditorShutdownFinalize;
|
||||||
|
|
||||||
|
/// Queue a guarded shutdown from an exclusive-world UI or workflow callback.
|
||||||
|
pub fn request_editor_shutdown(world: &mut World, source: ShutdownSource) -> bool {
|
||||||
|
world
|
||||||
|
.get_resource_mut::<ShutdownCoordinator>()
|
||||||
|
.is_some_and(|mut coordinator| coordinator.request(source))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queue a project-browser handoff through the same dirty-document guard as editor exit.
|
||||||
|
pub fn request_project_switch(world: &mut World) -> bool {
|
||||||
|
request_editor_shutdown(world, ShutdownSource::SwitchProject)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the dirty-scene confirmation through the shared coordinator.
|
||||||
|
pub fn resolve_shutdown_decision(world: &mut World, decision: ShutdownDecision) -> bool {
|
||||||
|
let (save_request, status) = {
|
||||||
|
let Some(mut coordinator) = world.get_resource_mut::<ShutdownCoordinator>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let ShutdownIntent::Confirming { source } = coordinator.intent else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match decision {
|
||||||
|
ShutdownDecision::SaveAll => {
|
||||||
|
coordinator.intent = ShutdownIntent::Saving { source };
|
||||||
|
(
|
||||||
|
Some(source),
|
||||||
|
Some("Saving all modified scene tabs before shutdown"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShutdownDecision::Discard => {
|
||||||
|
coordinator.intent = ShutdownIntent::Authorized {
|
||||||
|
source,
|
||||||
|
require_clean_scenes: false,
|
||||||
|
};
|
||||||
|
(None, None)
|
||||||
|
}
|
||||||
|
ShutdownDecision::Cancel => {
|
||||||
|
coordinator.intent = ShutdownIntent::Idle;
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
Some("Shutdown cancelled; unsaved scene tabs retained"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(status) = status {
|
||||||
|
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
|
||||||
|
scene_io.set_status(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(source) = save_request {
|
||||||
|
world.write_message(ShutdownSaveAllRequested { source });
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish the save-all handoff. A reported success is authorized only after
|
||||||
|
/// every scene tab is observably clean.
|
||||||
|
pub fn complete_shutdown_save(world: &mut World, outcome: ShutdownSaveOutcome) -> bool {
|
||||||
|
let scenes_are_clean = !scene_has_unsaved_changes(world);
|
||||||
|
let Some(mut coordinator) = world.get_resource_mut::<ShutdownCoordinator>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let ShutdownIntent::Saving { source } = coordinator.intent else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
ShutdownSaveOutcome::Saved if scenes_are_clean => {
|
||||||
|
coordinator.intent = ShutdownIntent::Authorized {
|
||||||
|
source,
|
||||||
|
require_clean_scenes: true,
|
||||||
|
};
|
||||||
|
coordinator.last_blocker = None;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
ShutdownSaveOutcome::Saved => {
|
||||||
|
coordinator.intent = ShutdownIntent::Idle;
|
||||||
|
coordinator.last_blocker =
|
||||||
|
Some("Save All completed while unsaved scene tabs remain".into());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShutdownSaveOutcome::Cancelled => {
|
||||||
|
coordinator.intent = ShutdownIntent::Idle;
|
||||||
|
coordinator.last_blocker = None;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
ShutdownSaveOutcome::Failed(error) => {
|
||||||
|
coordinator.intent = ShutdownIntent::Idle;
|
||||||
|
coordinator.last_blocker = Some(error);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ShutdownPlugin;
|
||||||
|
|
||||||
|
impl Plugin for ShutdownPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_schedule(EditorShutdownFinalize);
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<MainScheduleOrder>()
|
||||||
|
.insert_after(Last, EditorShutdownFinalize);
|
||||||
|
app.init_resource::<ShutdownCoordinator>()
|
||||||
|
.add_message::<ShutdownSaveAllRequested>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(capture_native_close_requests, drive_shutdown).chain(),
|
||||||
|
)
|
||||||
|
.add_systems(EditorShutdownFinalize, finalize_authorized_shutdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_native_close_requests(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut close_requests: MessageReader<WindowCloseRequested>,
|
||||||
|
primary_window: Query<Entity, With<PrimaryWindow>>,
|
||||||
|
mut coordinator: ResMut<ShutdownCoordinator>,
|
||||||
|
) {
|
||||||
|
let Ok(primary_window) = primary_window.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for request in close_requests.read() {
|
||||||
|
if request.window == primary_window {
|
||||||
|
coordinator.request(ShutdownSource::NativeWindow);
|
||||||
|
} else {
|
||||||
|
commands.entity(request.window).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive_shutdown(world: &mut World) {
|
||||||
|
let intent = world.resource::<ShutdownCoordinator>().intent;
|
||||||
|
match intent {
|
||||||
|
ShutdownIntent::Idle
|
||||||
|
| ShutdownIntent::Confirming { .. }
|
||||||
|
| ShutdownIntent::Saving { .. }
|
||||||
|
| ShutdownIntent::ExitSent { .. } => {}
|
||||||
|
ShutdownIntent::WaitingForBroker { source } => {
|
||||||
|
let unsaved_count = unsaved_scene_count(world);
|
||||||
|
if unsaved_count == 0 {
|
||||||
|
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::Authorized {
|
||||||
|
source,
|
||||||
|
require_clean_scenes: true,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if world.resource::<NativeDialogBroker>().is_pending() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let action = if source == ShutdownSource::SwitchProject {
|
||||||
|
"switching projects"
|
||||||
|
} else {
|
||||||
|
"quitting"
|
||||||
|
};
|
||||||
|
let description = if unsaved_count == 1 {
|
||||||
|
format!("One scene tab has unsaved changes. Save it before {action}?")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{unsaved_count} scene tabs have unsaved changes. Save all before {action}?"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let title = if source == ShutdownSource::SwitchProject {
|
||||||
|
"Switch Project"
|
||||||
|
} else {
|
||||||
|
"Quit Blacksite Editor"
|
||||||
|
};
|
||||||
|
let request = world.resource::<NativeDialogBroker>().request(
|
||||||
|
move || {
|
||||||
|
rfd::MessageDialog::new()
|
||||||
|
.set_title(title)
|
||||||
|
.set_description(description)
|
||||||
|
.set_level(rfd::MessageLevel::Warning)
|
||||||
|
.set_buttons(rfd::MessageButtons::YesNoCancelCustom(
|
||||||
|
SAVE_ALL_LABEL.into(),
|
||||||
|
DISCARD_LABEL.into(),
|
||||||
|
CANCEL_LABEL.into(),
|
||||||
|
))
|
||||||
|
.show()
|
||||||
|
},
|
||||||
|
|world, result| {
|
||||||
|
resolve_shutdown_decision(world, decision_from_dialog(result));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if request.is_ok() {
|
||||||
|
world.resource_mut::<ShutdownCoordinator>().intent =
|
||||||
|
ShutdownIntent::Confirming { source };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShutdownIntent::Authorized { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final dirty-state recheck after editor UI and authoring schedules have completed.
|
||||||
|
pub(crate) fn finalize_authorized_shutdown(world: &mut World) {
|
||||||
|
let (source, require_clean_scenes) = {
|
||||||
|
let coordinator = world.resource::<ShutdownCoordinator>();
|
||||||
|
let ShutdownIntent::Authorized {
|
||||||
|
source,
|
||||||
|
require_clean_scenes,
|
||||||
|
} = coordinator.intent
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
(source, require_clean_scenes)
|
||||||
|
};
|
||||||
|
if require_clean_scenes && scene_has_unsaved_changes(world) {
|
||||||
|
world.resource_mut::<ShutdownCoordinator>().intent =
|
||||||
|
ShutdownIntent::WaitingForBroker { source };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if source == ShutdownSource::SwitchProject {
|
||||||
|
if let Err(error) = crate::launcher::spawn_project_launcher_process() {
|
||||||
|
let message = format!("Switch project failed: {error}");
|
||||||
|
{
|
||||||
|
let mut coordinator = world.resource_mut::<ShutdownCoordinator>();
|
||||||
|
coordinator.intent = ShutdownIntent::Idle;
|
||||||
|
coordinator.last_blocker = Some(message.clone());
|
||||||
|
}
|
||||||
|
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
|
||||||
|
scene_io.set_status(message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::ExitSent { source };
|
||||||
|
debug!(?source, "Editor shutdown authorized");
|
||||||
|
world.write_message(AppExit::Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scene_has_unsaved_changes(world: &World) -> bool {
|
||||||
|
world
|
||||||
|
.get_resource::<SceneIo>()
|
||||||
|
.is_some_and(SceneIo::has_unsaved_tabs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsaved_scene_count(world: &World) -> usize {
|
||||||
|
let Some(scene_io) = world.get_resource::<SceneIo>() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let tab_count = scene_io.tabs.iter().filter(|tab| tab.dirty).count();
|
||||||
|
if scene_io.dirty
|
||||||
|
&& scene_io
|
||||||
|
.tabs
|
||||||
|
.get(scene_io.active_tab)
|
||||||
|
.is_none_or(|tab| !tab.dirty)
|
||||||
|
{
|
||||||
|
tab_count + 1
|
||||||
|
} else {
|
||||||
|
tab_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decision_from_dialog(result: rfd::MessageDialogResult) -> ShutdownDecision {
|
||||||
|
match result {
|
||||||
|
rfd::MessageDialogResult::Custom(label) if label == SAVE_ALL_LABEL => {
|
||||||
|
ShutdownDecision::SaveAll
|
||||||
|
}
|
||||||
|
rfd::MessageDialogResult::Custom(label) if label == DISCARD_LABEL => {
|
||||||
|
ShutdownDecision::Discard
|
||||||
|
}
|
||||||
|
rfd::MessageDialogResult::Yes => ShutdownDecision::SaveAll,
|
||||||
|
rfd::MessageDialogResult::No => ShutdownDecision::Discard,
|
||||||
|
_ => ShutdownDecision::Cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
use bevy::ecs::message::MessageCursor;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::native_dialog::NativeDialogPlugin;
|
||||||
|
|
||||||
|
fn confirming_world() -> World {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<Messages<ShutdownSaveAllRequested>>();
|
||||||
|
world.insert_resource(ShutdownCoordinator {
|
||||||
|
intent: ShutdownIntent::Confirming {
|
||||||
|
source: ShutdownSource::FileMenu,
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
});
|
||||||
|
world
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_requests_are_coalesced_behind_the_first_source() {
|
||||||
|
let mut coordinator = ShutdownCoordinator::default();
|
||||||
|
|
||||||
|
assert!(coordinator.request(ShutdownSource::NativeWindow));
|
||||||
|
assert!(!coordinator.request(ShutdownSource::FileMenu));
|
||||||
|
assert_eq!(coordinator.coalesced_requests(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
coordinator.intent(),
|
||||||
|
ShutdownIntent::WaitingForBroker {
|
||||||
|
source: ShutdownSource::NativeWindow
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_returns_to_idle_and_discard_authorizes_exit() {
|
||||||
|
let mut cancel_world = confirming_world();
|
||||||
|
assert!(resolve_shutdown_decision(
|
||||||
|
&mut cancel_world,
|
||||||
|
ShutdownDecision::Cancel
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
cancel_world.resource::<ShutdownCoordinator>().intent(),
|
||||||
|
ShutdownIntent::Idle
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut discard_world = confirming_world();
|
||||||
|
assert!(resolve_shutdown_decision(
|
||||||
|
&mut discard_world,
|
||||||
|
ShutdownDecision::Discard
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
discard_world.resource::<ShutdownCoordinator>().intent(),
|
||||||
|
ShutdownIntent::Authorized {
|
||||||
|
source: ShutdownSource::FileMenu,
|
||||||
|
require_clean_scenes: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dirty_cancel_preserves_the_session_and_discard_emits_one_exit() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_message::<AppExit>()
|
||||||
|
.init_resource::<SceneIo>()
|
||||||
|
.init_resource::<ShutdownCoordinator>();
|
||||||
|
app.world_mut().resource_mut::<SceneIo>().mark_dirty();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ShutdownCoordinator>()
|
||||||
|
.set_intent_for_test(ShutdownIntent::Confirming {
|
||||||
|
source: ShutdownSource::Programmatic,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(resolve_shutdown_decision(
|
||||||
|
app.world_mut(),
|
||||||
|
ShutdownDecision::Cancel
|
||||||
|
));
|
||||||
|
drive_shutdown(app.world_mut());
|
||||||
|
finalize_authorized_shutdown(app.world_mut());
|
||||||
|
assert_eq!(app.should_exit(), None);
|
||||||
|
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ShutdownCoordinator>()
|
||||||
|
.set_intent_for_test(ShutdownIntent::Confirming {
|
||||||
|
source: ShutdownSource::Programmatic,
|
||||||
|
});
|
||||||
|
assert!(resolve_shutdown_decision(
|
||||||
|
app.world_mut(),
|
||||||
|
ShutdownDecision::Discard
|
||||||
|
));
|
||||||
|
drive_shutdown(app.world_mut());
|
||||||
|
finalize_authorized_shutdown(app.world_mut());
|
||||||
|
assert_eq!(app.should_exit(), Some(AppExit::Success));
|
||||||
|
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
|
||||||
|
assert!(app
|
||||||
|
.world()
|
||||||
|
.resource::<ShutdownCoordinator>()
|
||||||
|
.authorized_exit_sent());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_menu_and_programmatic_clean_exit_use_the_same_coordinator_path() {
|
||||||
|
for source in [ShutdownSource::FileMenu, ShutdownSource::Programmatic] {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_message::<AppExit>()
|
||||||
|
.init_resource::<SceneIo>()
|
||||||
|
.init_resource::<ShutdownCoordinator>()
|
||||||
|
.init_resource::<NativeDialogBroker>();
|
||||||
|
|
||||||
|
assert!(request_editor_shutdown(app.world_mut(), source));
|
||||||
|
drive_shutdown(app.world_mut());
|
||||||
|
finalize_authorized_shutdown(app.world_mut());
|
||||||
|
|
||||||
|
assert_eq!(app.should_exit(), Some(AppExit::Success));
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<ShutdownCoordinator>().intent(),
|
||||||
|
ShutdownIntent::ExitSent { source }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_all_emits_one_persistence_handoff() {
|
||||||
|
let mut world = confirming_world();
|
||||||
|
let mut cursor = MessageCursor::<ShutdownSaveAllRequested>::default();
|
||||||
|
|
||||||
|
assert!(resolve_shutdown_decision(
|
||||||
|
&mut world,
|
||||||
|
ShutdownDecision::SaveAll
|
||||||
|
));
|
||||||
|
assert!(!resolve_shutdown_decision(
|
||||||
|
&mut world,
|
||||||
|
ShutdownDecision::SaveAll
|
||||||
|
));
|
||||||
|
|
||||||
|
let messages = world.resource::<Messages<ShutdownSaveAllRequested>>();
|
||||||
|
let requests: Vec<_> = cursor.read(messages).copied().collect();
|
||||||
|
assert_eq!(
|
||||||
|
requests,
|
||||||
|
vec![ShutdownSaveAllRequested {
|
||||||
|
source: ShutdownSource::FileMenu
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clean_native_close_emits_the_authorized_exit() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_message::<WindowCloseRequested>()
|
||||||
|
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
|
||||||
|
.init_resource::<SceneIo>();
|
||||||
|
let window = app.world_mut().spawn(PrimaryWindow).id();
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(WindowCloseRequested { window });
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(app.should_exit(), Some(AppExit::Success));
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<ShutdownCoordinator>().intent(),
|
||||||
|
ShutdownIntent::ExitSent {
|
||||||
|
source: ShutdownSource::NativeWindow
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secondary_window_close_does_not_request_editor_shutdown() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_message::<WindowCloseRequested>()
|
||||||
|
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
|
||||||
|
.init_resource::<SceneIo>();
|
||||||
|
app.world_mut().spawn(PrimaryWindow);
|
||||||
|
let secondary = app.world_mut().spawn_empty().id();
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(WindowCloseRequested { window: secondary });
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(app.should_exit(), None);
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<ShutdownCoordinator>().intent(),
|
||||||
|
ShutdownIntent::Idle
|
||||||
|
);
|
||||||
|
assert!(app.world().get_entity(secondary).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn occupied_dialog_broker_keeps_shutdown_waiting() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_message::<WindowCloseRequested>()
|
||||||
|
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
|
||||||
|
.init_resource::<SceneIo>();
|
||||||
|
app.world_mut().resource_mut::<SceneIo>().mark_dirty();
|
||||||
|
let (release_sender, release_receiver) = mpsc::channel();
|
||||||
|
app.world()
|
||||||
|
.resource::<NativeDialogBroker>()
|
||||||
|
.request(
|
||||||
|
move || release_receiver.recv().expect("release sender dropped"),
|
||||||
|
|_, _: ()| {},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(request_editor_shutdown(
|
||||||
|
app.world_mut(),
|
||||||
|
ShutdownSource::Programmatic
|
||||||
|
));
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<ShutdownCoordinator>().intent(),
|
||||||
|
ShutdownIntent::WaitingForBroker {
|
||||||
|
source: ShutdownSource::Programmatic
|
||||||
|
}
|
||||||
|
);
|
||||||
|
release_sender.send(()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn successful_save_requires_every_scene_tab_to_be_clean() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
world.insert_resource(ShutdownCoordinator {
|
||||||
|
intent: ShutdownIntent::Saving {
|
||||||
|
source: ShutdownSource::Programmatic,
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(complete_shutdown_save(
|
||||||
|
&mut world,
|
||||||
|
ShutdownSaveOutcome::Saved
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
world.resource::<ShutdownCoordinator>().intent(),
|
||||||
|
ShutdownIntent::Authorized {
|
||||||
|
source: ShutdownSource::Programmatic,
|
||||||
|
require_clean_scenes: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
world.resource_mut::<SceneIo>().mark_dirty();
|
||||||
|
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::Saving {
|
||||||
|
source: ShutdownSource::Programmatic,
|
||||||
|
};
|
||||||
|
assert!(!complete_shutdown_save(
|
||||||
|
&mut world,
|
||||||
|
ShutdownSaveOutcome::Saved
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
world.resource::<ShutdownCoordinator>().intent(),
|
||||||
|
ShutdownIntent::Idle
|
||||||
|
);
|
||||||
|
assert!(world
|
||||||
|
.resource::<ShutdownCoordinator>()
|
||||||
|
.last_blocker()
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_edit_after_save_reopens_the_guard_instead_of_exiting() {
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<Messages<AppExit>>();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
world.insert_resource(ShutdownCoordinator {
|
||||||
|
intent: ShutdownIntent::Authorized {
|
||||||
|
source: ShutdownSource::Programmatic,
|
||||||
|
require_clean_scenes: true,
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
});
|
||||||
|
world.resource_mut::<SceneIo>().mark_dirty();
|
||||||
|
|
||||||
|
finalize_authorized_shutdown(&mut world);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
world.resource::<ShutdownCoordinator>().intent(),
|
||||||
|
ShutdownIntent::WaitingForBroker {
|
||||||
|
source: ShutdownSource::Programmatic
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let messages = world.resource::<Messages<AppExit>>();
|
||||||
|
assert_eq!(
|
||||||
|
MessageCursor::<AppExit>::default().read(messages).count(),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_schedule_edit_is_seen_before_final_exit_authorization() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_message::<WindowCloseRequested>()
|
||||||
|
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
|
||||||
|
.init_resource::<SceneIo>()
|
||||||
|
.add_systems(Last, |mut scene_io: ResMut<SceneIo>| {
|
||||||
|
scene_io.mark_dirty();
|
||||||
|
});
|
||||||
|
assert!(request_editor_shutdown(
|
||||||
|
app.world_mut(),
|
||||||
|
ShutdownSource::Programmatic
|
||||||
|
));
|
||||||
|
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(app.should_exit(), None);
|
||||||
|
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<ShutdownCoordinator>().intent(),
|
||||||
|
ShutdownIntent::WaitingForBroker {
|
||||||
|
source: ShutdownSource::Programmatic
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_dialog_labels_map_to_shutdown_decisions() {
|
||||||
|
assert_eq!(
|
||||||
|
decision_from_dialog(rfd::MessageDialogResult::Custom(SAVE_ALL_LABEL.into())),
|
||||||
|
ShutdownDecision::SaveAll
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decision_from_dialog(rfd::MessageDialogResult::Custom(DISCARD_LABEL.into())),
|
||||||
|
ShutdownDecision::Discard
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decision_from_dialog(rfd::MessageDialogResult::Custom(CANCEL_LABEL.into())),
|
||||||
|
ShutdownDecision::Cancel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_final_coordinator_state_authorizes_clean_session_persistence() {
|
||||||
|
let mut coordinator = ShutdownCoordinator::default();
|
||||||
|
assert!(!coordinator.authorized_exit_sent());
|
||||||
|
|
||||||
|
coordinator.intent = ShutdownIntent::Authorized {
|
||||||
|
source: ShutdownSource::Programmatic,
|
||||||
|
require_clean_scenes: true,
|
||||||
|
};
|
||||||
|
assert!(!coordinator.authorized_exit_sent());
|
||||||
|
|
||||||
|
coordinator.intent = ShutdownIntent::ExitSent {
|
||||||
|
source: ShutdownSource::Programmatic,
|
||||||
|
};
|
||||||
|
assert!(coordinator.authorized_exit_sent());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,7 @@ use crate::history::{apply_command_redo, apply_command_undo, EditorHistory};
|
|||||||
use crate::project::samples::{SampleCatalog, SampleCatalogEntry};
|
use crate::project::samples::{SampleCatalog, SampleCatalogEntry};
|
||||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||||
use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel};
|
use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel};
|
||||||
|
use crate::shutdown::{request_editor_shutdown, ShutdownSource};
|
||||||
use crate::state::{EditorMode, PlayPaused};
|
use crate::state::{EditorMode, PlayPaused};
|
||||||
|
|
||||||
use super::diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
use super::diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
||||||
@ -148,6 +149,11 @@ pub fn top_menu_bar(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
ui.separator();
|
||||||
|
if menu_item(ui, "Quit", None, true).clicked() {
|
||||||
|
request_editor_shutdown(world, ShutdownSource::FileMenu);
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.menu_button("Edit", |ui| {
|
ui.menu_button("Edit", |ui| {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use bevy::app::PluginGroupBuilder;
|
|||||||
use bevy::asset::AssetPlugin;
|
use bevy::asset::AssetPlugin;
|
||||||
use bevy::log::{LogPlugin, DEFAULT_FILTER};
|
use bevy::log::{LogPlugin, DEFAULT_FILTER};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{CompositeAlphaMode, PresentMode, WindowMode, WindowPlugin};
|
use bevy::window::{CompositeAlphaMode, ExitCondition, PresentMode, WindowMode, WindowPlugin};
|
||||||
|
|
||||||
/// Runtime switch for the HDR camera pass.
|
/// Runtime switch for the HDR camera pass.
|
||||||
///
|
///
|
||||||
@ -40,6 +40,27 @@ fn find_assets_directory(start: Option<PathBuf>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_plugins(title: impl Into<String>) -> PluginGroupBuilder {
|
pub fn default_plugins(title: impl Into<String>) -> PluginGroupBuilder {
|
||||||
|
configured_plugins(WindowPlugin {
|
||||||
|
primary_window: Some(primary_window(title)),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default plugins for the editor, whose shutdown coordinator owns window-close handling.
|
||||||
|
pub fn editor_plugins(title: impl Into<String>) -> PluginGroupBuilder {
|
||||||
|
configured_plugins(editor_window_plugin(title))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor_window_plugin(title: impl Into<String>) -> WindowPlugin {
|
||||||
|
WindowPlugin {
|
||||||
|
primary_window: Some(primary_window(title)),
|
||||||
|
exit_condition: ExitCondition::DontExit,
|
||||||
|
close_when_requested: false,
|
||||||
|
..default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configured_plugins(window_plugin: WindowPlugin) -> PluginGroupBuilder {
|
||||||
DefaultPlugins
|
DefaultPlugins
|
||||||
.set(LogPlugin {
|
.set(LogPlugin {
|
||||||
// Blacksite intentionally replaces Bevy's `.scn.ron` loader with a
|
// Blacksite intentionally replaces Bevy's `.scn.ron` loader with a
|
||||||
@ -48,10 +69,7 @@ pub fn default_plugins(title: impl Into<String>) -> PluginGroupBuilder {
|
|||||||
filter: format!("{DEFAULT_FILTER}bevy_asset::server::loaders=error"),
|
filter: format!("{DEFAULT_FILTER}bevy_asset::server::loaders=error"),
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.set(WindowPlugin {
|
.set(window_plugin)
|
||||||
primary_window: Some(primary_window(title)),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.set(AssetPlugin {
|
.set(AssetPlugin {
|
||||||
file_path: resolve_assets_directory(),
|
file_path: resolve_assets_directory(),
|
||||||
..default()
|
..default()
|
||||||
@ -86,8 +104,8 @@ pub fn hdr_enabled_from_env() -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{primary_window, resolve_assets_directory};
|
use super::{editor_window_plugin, primary_window, resolve_assets_directory};
|
||||||
use bevy::window::CompositeAlphaMode;
|
use bevy::window::{CompositeAlphaMode, ExitCondition};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn primary_window_is_opaque() {
|
fn primary_window_is_opaque() {
|
||||||
@ -97,6 +115,14 @@ mod tests {
|
|||||||
assert_eq!(window.composite_alpha_mode, CompositeAlphaMode::Opaque);
|
assert_eq!(window.composite_alpha_mode, CompositeAlphaMode::Opaque);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_window_defers_close_and_exit_to_the_editor() {
|
||||||
|
let plugin = editor_window_plugin("test");
|
||||||
|
|
||||||
|
assert!(!plugin.close_when_requested);
|
||||||
|
assert!(matches!(plugin.exit_condition, ExitCondition::DontExit));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_assets_directory_finds_workspace_assets() {
|
fn resolve_assets_directory_finds_workspace_assets() {
|
||||||
let cwd = std::env::current_dir().expect("cwd");
|
let cwd = std::env::current_dir().expect("cwd");
|
||||||
|
|||||||
@ -56,6 +56,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
|||||||
| [0039](adr/0039-inline-height-grid-terrain-foundation.md) | Inline authored height grids with deterministic runtime-only chunk hydration |
|
| [0039](adr/0039-inline-height-grid-terrain-foundation.md) | Inline authored height grids with deterministic runtime-only chunk hydration |
|
||||||
| [0040](adr/0040-terrain-material-layer-weights.md) | Four-channel terrain material layers, compact normalized weights, and raster transport |
|
| [0040](adr/0040-terrain-material-layer-weights.md) | Four-channel terrain material layers, compact normalized weights, and raster transport |
|
||||||
| [0041](adr/0041-transactional-editor-physics-placement.md) | Paused editor physics ownership and transactional gravity placement |
|
| [0041](adr/0041-transactional-editor-physics-placement.md) | Paused editor physics ownership and transactional gravity placement |
|
||||||
|
| [0042](adr/0042-guarded-editor-shutdown-and-document-savepoints.md) | Guarded native editor exit and canonical per-document clean checkpoints |
|
||||||
|
|
||||||
## Editor framework
|
## Editor framework
|
||||||
|
|
||||||
@ -119,6 +120,7 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
|
|||||||
| `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting |
|
| `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting |
|
||||||
| `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance |
|
| `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance |
|
||||||
| `editor_sample_regression_pack_*.plan.md` | Five-area sample manifest, editor catalog, deterministic validation, and native regression acceptance |
|
| `editor_sample_regression_pack_*.plan.md` | Five-area sample manifest, editor catalog, deterministic validation, and native regression acceptance |
|
||||||
|
| `guarded_shutdown_savepoints_*.plan.md` | Native close coordination, asynchronous Save All, and canonical history clean points |
|
||||||
|
|
||||||
## Crate responsibilities (quick reference)
|
## Crate responsibilities (quick reference)
|
||||||
|
|
||||||
|
|||||||
@ -34,12 +34,19 @@ assets, mark a scene clean, enter source control, or silently replace authored w
|
|||||||
or Discard removes that scene's recovery generations. Save Recovery Copy As writes the snapshot
|
or Discard removes that scene's recovery generations. Save Recovery Copy As writes the snapshot
|
||||||
to a separate transactional scene file without changing the active scene or recovery lifecycle.
|
to a separate transactional scene file without changing the active scene or recovery lifecycle.
|
||||||
- A successful manual save retires recovery generations for that scene.
|
- A successful manual save retires recovery generations for that scene.
|
||||||
|
- A successful load or manual save establishes that tab's canonical authored-content checkpoint.
|
||||||
|
Undo and redo compare the current stable authored projection with this checkpoint to derive dirty
|
||||||
|
state. Failed writes, cancelled Save As, and recovery restores do not advance it.
|
||||||
|
- Native editor exit is authorized only after all dirty scene tabs save successfully or the user
|
||||||
|
explicitly discards them. See [ADR 0042](0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- Interrupted manual writes retain the previous complete destination on filesystems with atomic
|
- Interrupted manual writes retain the previous complete destination on filesystems with atomic
|
||||||
same-directory rename semantics.
|
same-directory rename semantics.
|
||||||
- Recovery is machine-local and does not pollute builds or source control.
|
- Recovery is machine-local and does not pollute builds or source control.
|
||||||
|
- Undoing exactly to the last loaded or saved authored state clears the dirty marker without
|
||||||
|
weakening recovery after direct edits or failed saves.
|
||||||
- Autosave still performs authored serialization and hydration rebuild work. Performance
|
- Autosave still performs authored serialization and hydration rebuild work. Performance
|
||||||
instrumentation and background snapshotting remain follow-up work under the production roadmap.
|
instrumentation and background snapshotting remain follow-up work under the production roadmap.
|
||||||
- Unsaved new scenes do not yet have a stable recovery identity; they require a later session-ID
|
- Unsaved new scenes do not yet have a stable recovery identity; they require a later session-ID
|
||||||
|
|||||||
@ -20,8 +20,10 @@ untrusted serialized ECS state.
|
|||||||
- Session schema v1 allowlists metadata only: project root, active scene path, saved dock-layout
|
- Session schema v1 allowlists metadata only: project root, active scene path, saved dock-layout
|
||||||
text, hierarchy expansion paths, non-destructive panel visibility, and finite camera bookmark
|
text, hierarchy expansion paths, non-destructive panel visibility, and finite camera bookmark
|
||||||
transforms.
|
transforms.
|
||||||
- Write the session transactionally. Startup immediately writes `clean_shutdown: false`; normal
|
- Write the session transactionally. Startup immediately writes `clean_shutdown: false`; only an
|
||||||
`AppExit` writes the final document with `clean_shutdown: true`.
|
editor-shutdown-coordinator-authorized `AppExit` writes the final document with
|
||||||
|
`clean_shutdown: true`. Direct `AppExit`, forced termination, and process failure retain the
|
||||||
|
abnormal marker. See [ADR 0042](0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||||
- A clean prior session may restore its active scene and non-destructive UI state automatically.
|
- A clean prior session may restore its active scene and non-destructive UI state automatically.
|
||||||
An abnormal prior session starts from the normal safe scene and requires explicit confirmation
|
An abnormal prior session starts from the normal safe scene and requires explicit confirmation
|
||||||
before opening the prior scene.
|
before opening the prior scene.
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
# ADR 0042: Guarded Editor Shutdown And Document Savepoints
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Bevy's default native-window policy closes a requested window and exits after the final window is
|
||||||
|
gone. That policy bypasses Blacksite's scene-tab and project-switch confirmations, so a compositor
|
||||||
|
close request could silently discard dirty authored work. Reading `WindowCloseRequested` in another
|
||||||
|
system is insufficient because Bevy's default close system has its own message cursor and still
|
||||||
|
despawns the window.
|
||||||
|
|
||||||
|
Scene dirtiness was also independent of editor history. Every undo or redo marked the active scene
|
||||||
|
dirty, even when undo restored the exact state that had last loaded or saved. Undo depth alone is
|
||||||
|
not a valid savepoint: editor commands contain runtime entity IDs and are intentionally cleared when
|
||||||
|
tabs switch, while saves may establish a clean point in the middle of a timeline.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- The full editor disables Bevy's automatic close-on-request behavior. The game and Project Browser
|
||||||
|
keep their existing native-window policy.
|
||||||
|
- One editor shutdown coordinator owns native window close, **File > Quit**, project switching, and
|
||||||
|
programmatic editor-exit requests. It coalesces duplicate requests and is the only normal path
|
||||||
|
authorized to emit `AppExit`.
|
||||||
|
- Final authorization runs in a dedicated main schedule after Bevy's `Last` schedule. The guard
|
||||||
|
rechecks dirty scene state after every editor authoring system, then clean-session persistence
|
||||||
|
runs after that finalizer in the same post-`Last` schedule.
|
||||||
|
- A clean editor exits immediately. Dirty scene tabs enter one non-blocking native decision with
|
||||||
|
explicit **Save All**, **Discard**, and **Cancel** actions. Save completion, including an untitled
|
||||||
|
tab's Save As picker, is applied on the main thread. The coordinator exits only after every dirty
|
||||||
|
tab saves or the user explicitly discards; cancel or failure retains the live session.
|
||||||
|
- Clean-session persistence accepts only an exit authorized by the coordinator. Crashes, forced
|
||||||
|
termination, and unguarded `AppExit` retain the abnormal-session marker.
|
||||||
|
- Every scene tab owns a canonical authored-content checkpoint established by a successful load or
|
||||||
|
save. The projection uses stable actor IDs, stable parent actor IDs, sorted component identities,
|
||||||
|
and scene composition; it excludes transient Bevy entity numbers and runtime hydration.
|
||||||
|
- A committed edit marks the active tab dirty. Successful undo or redo compares the current
|
||||||
|
canonical authored projection with that tab's checkpoint. Equality clears the dirty marker;
|
||||||
|
divergence sets it. Failed saves and recovery restores do not advance the checkpoint.
|
||||||
|
- Editor command stacks remain active-document runtime state and are still cleared on tab switches.
|
||||||
|
Checkpoint ownership belongs to the document tab, so dirty truth survives those clears.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Native close can no longer silently discard authored scene tabs, and the editor remains responsive
|
||||||
|
while confirmation or Save As dialogs are open.
|
||||||
|
- A normal exit requires an explicit editor-owned authorization. New code must request shutdown
|
||||||
|
through the coordinator instead of writing `AppExit` directly.
|
||||||
|
- Undoing to the last saved authored state clears title and tab dirty indicators, including after a
|
||||||
|
save at nonzero history depth. Redo or a divergent branch marks the document dirty again.
|
||||||
|
- Reconciliation serializes the authored projection after undo and redo. This is more work than a
|
||||||
|
depth comparison, but it is bounded to explicit history navigation and remains correct across
|
||||||
|
direct non-history repairs, entity respawns, and tab switches.
|
||||||
|
- Operating-system process kill and power loss cannot be confirmed; recovery and abnormal-session
|
||||||
|
handling remain the safety boundary for those cases.
|
||||||
@ -111,6 +111,10 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
|||||||
components on load and component transactions, and does not prohibit compatible composition such
|
components on load and component transactions, and does not prohibit compatible composition such
|
||||||
as renderer plus light. Component validation owns requirements and geometry-source conflicts.
|
as renderer plus light. Component validation owns requirements and geometry-source conflicts.
|
||||||
- **Scene persistence and recovery** use transactional same-directory writes for authored scenes and bounded user-local recovery snapshots for dirty saved scenes. Recovery runs every 120 seconds by default, keeps five generations, never marks the scene clean, and appears as a status/File-menu restore or discard action when newer than the authored file. Restore leaves the original scene dirty until manually saved. See [ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
|
- **Scene persistence and recovery** use transactional same-directory writes for authored scenes and bounded user-local recovery snapshots for dirty saved scenes. Recovery runs every 120 seconds by default, keeps five generations, never marks the scene clean, and appears as a status/File-menu restore or discard action when newer than the authored file. Restore leaves the original scene dirty until manually saved. See [ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
|
||||||
|
- **Guarded shutdown and clean savepoints** route native close, **File > Quit**, project switching,
|
||||||
|
and programmatic exit through one non-blocking Save All / Discard / Cancel coordinator. Each tab's
|
||||||
|
dirty marker is reconciled against its canonical last-loaded-or-saved authored projection after
|
||||||
|
undo/redo. See [ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||||
- **Collaborative file safety** records exact content revisions for loaded scenes, prefab source operations, editable materials, and Project Settings, then rechecks them immediately before atomic publication. External changes, create races, read-only files, and optional ownership locks keep the existing bytes intact and open Reload/Compare Metadata/Save As/Cancel recovery. Git status is asynchronous, observational, and absent without noise outside a repository. See [collaborative-file-safety.md](collaborative-file-safety.md) and [ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
|
- **Collaborative file safety** records exact content revisions for loaded scenes, prefab source operations, editable materials, and Project Settings, then rechecks them immediately before atomic publication. External changes, create races, read-only files, and optional ownership locks keep the existing bytes intact and open Reload/Compare Metadata/Save As/Cancel recovery. Git status is asynchronous, observational, and absent without noise outside a repository. See [collaborative-file-safety.md](collaborative-file-safety.md) and [ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
|
||||||
- **Scene visualizers** expose actor root icons, colliders, lights, player spawns, gameplay markers, volumes, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Spot lights show inner/outer cones and cameras show near/far frusta. Viewport options independently gate visualizer categories and include actor icon and transform-gizmo size sliders.
|
- **Scene visualizers** expose actor root icons, colliders, lights, player spawns, gameplay markers, volumes, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Spot lights show inner/outer cones and cameras show near/far frusta. Viewport options independently gate visualizer categories and include actor icon and transform-gizmo size sliders.
|
||||||
- **Clean game-view overlay** (`G`) hides editor chrome, grid, selection outlines, transform gizmos, actor root icons, visualizers, and selectable proxies without changing camera ownership; `Ctrl+G` toggles grid.
|
- **Clean game-view overlay** (`G`) hides editor chrome, grid, selection outlines, transform gizmos, actor root icons, visualizers, and selectable proxies without changing camera ownership; `Ctrl+G` toggles grid.
|
||||||
|
|||||||
@ -87,11 +87,20 @@ or Cancel. Generated artifacts remain under their subsystem-owned regeneration p
|
|||||||
[collaborative-file-safety.md](collaborative-file-safety.md) and
|
[collaborative-file-safety.md](collaborative-file-safety.md) and
|
||||||
[ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
|
[ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
|
||||||
|
|
||||||
|
`project/shutdown.rs` owns native close, **File > Quit**, project switching, and programmatic editor
|
||||||
|
exit. The editor launch configuration leaves native close requests open; the coordinator coalesces
|
||||||
|
them, retries the shared native-dialog broker when busy, and authorizes `AppExit` only after clean
|
||||||
|
state, successful Save All, or explicit Discard. Scene I/O owns the asynchronous per-tab save
|
||||||
|
transaction and reports completion back to the coordinator. Final authorization and clean-session
|
||||||
|
persistence run in an editor schedule inserted after Bevy's `Last`, so material drops, brush/gizmo
|
||||||
|
finalizers, and every other authoring system finish before the last dirty-state check. See
|
||||||
|
[ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||||
|
|
||||||
`project/session.rs` owns the independent versioned restart document under the user state
|
`project/session.rs` owns the independent versioned restart document under the user state
|
||||||
directory. It snapshots allowlisted paths, panel visibility, dock/hierarchy metadata, and camera
|
directory. It snapshots allowlisted paths, panel visibility, dock/hierarchy metadata, and camera
|
||||||
bookmarks, writes a running marker at startup, and records clean `AppExit`. Abnormal sessions do
|
bookmarks, writes a running marker at startup, and records clean state only for a coordinator-
|
||||||
not auto-open the prior scene; the explicit safe-resume prompt is the only path back. See
|
authorized `AppExit`. Abnormal sessions do not auto-open the prior scene; the explicit safe-resume
|
||||||
[ADR 0024](../adr/0024-versioned-editor-session-state.md).
|
prompt is the only path back. See [ADR 0024](../adr/0024-versioned-editor-session-state.md).
|
||||||
|
|
||||||
`project/diagnostics_bundle.rs` exports a separate, transactional support report from an explicit
|
`project/diagnostics_bundle.rs` exports a separate, transactional support report from an explicit
|
||||||
metadata allowlist. It summarizes build/renderer identity, project paths, dirty state, aggregate
|
metadata allowlist. It summarizes build/renderer identity, project paths, dirty state, aggregate
|
||||||
@ -200,7 +209,7 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
|
|||||||
1. User edits entities with `LevelObject` + reflectable components from `shared`.
|
1. User edits entities with `LevelObject` + reflectable components from `shared`.
|
||||||
2. `EditorOnly` entities (cameras, helpers) are filtered from hierarchy and save; visualizer proxies can be picked but resolve back to source entities.
|
2. `EditorOnly` entities (cameras, helpers) are filtered from hierarchy and save; visualizer proxies can be picked but resolve back to source entities.
|
||||||
3. Player placement is stored as `PlayerSpawn`; the runtime `Player` is never serialized.
|
3. Player placement is stored as `PlayerSpawn`; the runtime `Player` is never serialized.
|
||||||
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through `scene::document::SceneDocument`. The active tab is materialized in the ECS world; inactive tabs retain normalized authored snapshots and independent dirty/recovery state. `SceneComposition` resources reference validated project-relative subscenes by stable IDs, and runtime-only `ComposedSceneMember` ownership prevents child actors from being flattened into the owner save. See [multi-scene-composition.md](multi-scene-composition.md) and ADR 0026.
|
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through `scene::document::SceneDocument`. The active tab is materialized in the ECS world; inactive tabs retain normalized authored snapshots, canonical clean checkpoints, and independent dirty/recovery state. Undo/redo reconciles stable authored content rather than transient ECS entity IDs. `SceneComposition` resources reference validated project-relative subscenes by stable IDs, and runtime-only `ComposedSceneMember` ownership prevents child actors from being flattened into the owner save. See [multi-scene-composition.md](multi-scene-composition.md), ADR 0026, and [ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||||
5. Hydration systems in `game` spawn meshes, colliders, lights, static mesh renderer parts, and
|
5. Hydration systems in `game` spawn meshes, colliders, lights, static mesh renderer parts, and
|
||||||
dedicated skinned-model hierarchies at runtime. Level-object roots are initialized with Bevy
|
dedicated skinned-model hierarchies at runtime. Level-object roots are initialized with Bevy
|
||||||
visibility hierarchy components before generated children are attached, so authoring
|
visibility hierarchy components before generated children are attached, so authoring
|
||||||
|
|||||||
@ -6,6 +6,11 @@ save/discard/cancel prompt when needed. **File > Open Scene...** opens another t
|
|||||||
discarding the active document. Save and Save As affect the active tab, while project switching
|
discarding the active document. Save and Save As affect the active tab, while project switching
|
||||||
offers to save every modified tab.
|
offers to save every modified tab.
|
||||||
|
|
||||||
|
Each tab also retains a canonical checkpoint of the last successfully loaded or saved authored
|
||||||
|
projection. History commands remain active-tab runtime state, but undo and redo reconcile against
|
||||||
|
that document checkpoint. Returning exactly to the checkpoint clears only that tab's dirty marker;
|
||||||
|
redo, a divergent edit, failed save, or restored recovery remains dirty.
|
||||||
|
|
||||||
## Composition workflow
|
## Composition workflow
|
||||||
|
|
||||||
The **Composition** menu beside the tabs edits the active scene's persisted `SceneComposition`:
|
The **Composition** menu beside the tabs edits the active scene's persisted `SceneComposition`:
|
||||||
|
|||||||
@ -16,6 +16,13 @@ loop or editor rendering.
|
|||||||
- Collaborative Save As performs the same revision and destination checks after selection.
|
- 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
|
- 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.
|
remains pending, the transition pauses and can be retried after saving.
|
||||||
|
- Native window close, **File > Quit**, project switch, and programmatic editor exit share the
|
||||||
|
guarded-shutdown coordinator. Dirty scene tabs present exact **Save All**, **Discard**, and
|
||||||
|
**Cancel** actions. Save All remains pending through each untitled tab's Save As result and exits
|
||||||
|
only after every dirty tab is clean; cancellation or failure preserves the live session.
|
||||||
|
- A close request received while another native dialog owns the broker remains queued. The editor
|
||||||
|
rechecks dirty state and retries acquisition after that workflow completes instead of dropping
|
||||||
|
the request or opening a competing dialog.
|
||||||
|
|
||||||
## Covered Surfaces
|
## Covered Surfaces
|
||||||
|
|
||||||
@ -24,6 +31,8 @@ subscene selection, collaborative conflict copies, Project Browser folder select
|
|||||||
confirmations. New workflows must use the broker instead of calling `rfd` from an egui/Bevy system.
|
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).
|
See [ADR 0038](../adr/0038-non-blocking-native-dialog-broker.md).
|
||||||
|
Guarded exit ownership and document savepoints are defined by
|
||||||
|
[ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||||
|
|
||||||
Live native-Wayland acceptance is recorded in the
|
Live native-Wayland acceptance is recorded in the
|
||||||
[native-dialog responsiveness evaluation](evaluations/native-dialog-responsiveness/).
|
[native-dialog responsiveness evaluation](evaluations/native-dialog-responsiveness/).
|
||||||
|
|||||||
@ -13,10 +13,14 @@ or credentials.
|
|||||||
|
|
||||||
## Restart behavior
|
## Restart behavior
|
||||||
|
|
||||||
- **Clean shutdown:** the last authored scene and non-destructive UI state are restored.
|
- **Clean shutdown:** after the guarded exit path saves all dirty scene tabs or receives explicit
|
||||||
|
Discard confirmation, the last authored scene and non-destructive UI state are restored.
|
||||||
- **Abnormal shutdown:** the editor opens its normal safe scene and displays **Recover Editor
|
- **Abnormal shutdown:** the editor opens its normal safe scene and displays **Recover Editor
|
||||||
Session**. **Resume Last Scene** opens only the previous authored scene. **Continue Safe** keeps
|
Session**. **Resume Last Scene** opens only the previous authored scene. **Continue Safe** keeps
|
||||||
the startup scene. Modal tools and dirty preview state are never restored.
|
the startup scene. Modal tools and dirty preview state are never restored.
|
||||||
|
- Native window close, **File > Quit**, project switching, and programmatic editor exit use one
|
||||||
|
non-blocking Save All / Discard / Cancel workflow. Cancel and failed saves keep the session open.
|
||||||
|
Direct `AppExit`, process kill, and crashes are not recorded as clean shutdowns.
|
||||||
- Dirty scene data remains governed by the independent scene recovery workflow in
|
- Dirty scene data remains governed by the independent scene recovery workflow in
|
||||||
[ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
|
[ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user