Add collaborative authored-file safety
This commit is contained in:
parent
b4aa61e394
commit
5a82a9ee29
@ -21,9 +21,23 @@ performance, soak, regression, and first-hour workflows must pass against a repr
|
|||||||
`#43`; M6 implementation is complete.
|
`#43`; M6 implementation is complete.
|
||||||
4. Completed: authoritative content validation `#45` and the validation-gated packaging/Build
|
4. Completed: authoritative content validation `#45` and the validation-gated packaging/Build
|
||||||
workflow `#44`.
|
workflow `#44`.
|
||||||
5. Completed: audio source/listener/bus authoring and spatial preview passed production acceptance
|
5. Completed: audio `#47`, animation `#46`, and navigation `#48` passed production acceptance.
|
||||||
in `#47`. Next: animation `#46`, navigation `#48`, source-control safety `#49`, then readiness
|
Source-control safety `#49` has complete implementation and source/headless acceptance; live
|
||||||
gate `#50`.
|
visual acceptance remains before the readiness gate `#50`.
|
||||||
|
|
||||||
|
## Implemented Slice - Collaborative Authored-File Safety
|
||||||
|
|
||||||
|
- Exact BLAKE3 baselines for scene tabs, staged Material/Material Instance documents, and Project
|
||||||
|
Settings.
|
||||||
|
- One guarded atomic publication path for scene save/export, prefab source Apply/history, and
|
||||||
|
editable materials, including missing-file and immediate pre-rename race protection.
|
||||||
|
- Non-destructive background Git porcelain scanning with optional off-thread ownership providers;
|
||||||
|
Git/provider absence is quiet.
|
||||||
|
- Compact active-scene and selected-asset state plus Reload, metadata comparison, Save As, and
|
||||||
|
Cancel conflict recovery. No force-overwrite action exists.
|
||||||
|
- Focused race, read-only, provider-lock, parser, non-repository, modal-render, scene, and material
|
||||||
|
tests pass. Live debug-editor visual acceptance remains pending for a desktop-capable QA session;
|
||||||
|
packaged acceptance remains owner-deferred.
|
||||||
|
|
||||||
## M6 - Reliability, Recovery, And Project Workflow
|
## M6 - Reliability, Recovery, And Project Workflow
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
# Source-Control Status And Collaborative File Safety
|
||||||
|
|
||||||
|
Working implementation plan for Gitea issue
|
||||||
|
[`#49`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/49).
|
||||||
|
This is the final M7 implementation slice before the production-readiness acceptance gate.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implementation, focused source tests, full editor tests, strict editor clippy, and headless checks
|
||||||
|
are complete. Live debug-editor visual acceptance remains pending for a desktop-capable QA session.
|
||||||
|
Packaged/release acceptance is explicitly deferred until the project owner requests another
|
||||||
|
packaged pass.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
External edits cannot be silently overwritten by the editor, common Git/read-only/ownership states
|
||||||
|
are visible without dominating the shell, and projects without Git or an ownership provider behave
|
||||||
|
normally.
|
||||||
|
|
||||||
|
## Implementation Sequence
|
||||||
|
|
||||||
|
1. Add exact file revisions, comparison metadata, guarded atomic replacement, and deterministic
|
||||||
|
pre-rename race coverage.
|
||||||
|
2. Capture baselines for scene tabs, material/material-instance drafts, and Project Settings;
|
||||||
|
protect prefab source Apply and exact-file undo/redo with the same guard.
|
||||||
|
3. Add a non-destructive asynchronous Git scanner and optional ownership-provider registry.
|
||||||
|
4. Add compact active-scene and selected-asset status plus a recovery modal with reload, compare
|
||||||
|
metadata, save-as, and cancel.
|
||||||
|
5. Cover missing/present/read-only/external-change races, porcelain parsing, provider absence, and
|
||||||
|
document-specific conflict recovery with focused source tests.
|
||||||
|
6. Run format, strict affected-crate clippy, source tests, headless validation, and live debug-editor
|
||||||
|
QA. Do not run packaged tests until requested by the project owner.
|
||||||
|
|
||||||
|
## Acceptance Gates
|
||||||
|
|
||||||
|
- A loaded authored file changed by another process is never replaced by Save, Apply to source, or
|
||||||
|
prefab source history.
|
||||||
|
- File creation is also conditional: a file that appears after a save-as baseline is captured is
|
||||||
|
not overwritten.
|
||||||
|
- Conflict and read-only UI identifies the path and recovery choices without a force-overwrite
|
||||||
|
escape hatch.
|
||||||
|
- Active-scene and selected-asset Git status remain compact and explain themselves on hover.
|
||||||
|
- Git absence, a non-repository project, and an empty provider registry are quiet normal states.
|
||||||
|
- Git commands are read-only and never alter index, worktree, commits, branches, or locks.
|
||||||
|
- Provider state can block an authored write without coupling core editor code to a vendor.
|
||||||
|
- Source tests simulate a writer changing the target immediately before rename and prove the old or
|
||||||
|
external content survives.
|
||||||
|
|
||||||
|
## Deliberate Boundaries
|
||||||
|
|
||||||
|
- Generated registries, imports, thumbnails, navigation bakes, recovery snapshots, and packages are
|
||||||
|
regenerative/system-owned and do not open authored-file recovery UI.
|
||||||
|
- V1 compares metadata rather than rendering a text diff; external diff-tool integration is future
|
||||||
|
work.
|
||||||
|
- V1 observes ownership/locks. Acquiring, releasing, or stealing locks belongs in a provider-specific
|
||||||
|
follow-up.
|
||||||
|
- Git status is project-local and advisory. The content revision guard remains authoritative even
|
||||||
|
when Git is absent or stale.
|
||||||
21
README.md
21
README.md
@ -161,6 +161,8 @@ deep-stale variants.
|
|||||||
| 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, Open, transactional Save/Save As, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes |
|
| File menu | New, Open, transactional Save/Save As, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes |
|
||||||
|
| 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 |
|
||||||
| 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 |
|
||||||
| Prefab Instance inspector | Inspect/recover source health, Apply/Revert overrides by scope, Apply overrides to source, create a variant, **Unpack Layer**, or recursively **Convert to Local** |
|
| Prefab Instance inspector | Inspect/recover source health, Apply/Revert overrides by scope, Apply overrides to source, create a variant, **Unpack Layer**, or recursively **Convert to Local** |
|
||||||
| Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions |
|
| Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions |
|
||||||
@ -261,6 +263,13 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
|
|||||||
without changing the scene.
|
without changing the scene.
|
||||||
- Runtime-only handles/colliders are not serialized directly, keeping scenes stable and portable.
|
- Runtime-only handles/colliders are not serialized directly, keeping scenes stable and portable.
|
||||||
- Editor-only cameras and helper roots are filtered from selection, hierarchy, and scene save.
|
- Editor-only cameras and helper roots are filtered from selection, hierarchy, and scene save.
|
||||||
|
- Loaded scenes, prefab source Apply/history, Materials, Material Instances, and Project Settings
|
||||||
|
retain exact BLAKE3 disk revisions. Save verifies the revision again immediately before atomic
|
||||||
|
replacement; an external edit, create race, read-only target, or provider lock leaves the
|
||||||
|
existing file untouched and opens the collaborative recovery dialog. Git status is observational
|
||||||
|
and optional. See the
|
||||||
|
[collaborative file-safety guide](docs/editor/collaborative-file-safety.md) and
|
||||||
|
[ADR 0037](docs/adr/0037-collaborative-authored-file-safety.md).
|
||||||
- **PIE restores player sim only** (transform, velocity, jump state) when you stop Play; authored
|
- **PIE restores player sim only** (transform, velocity, jump state) when you stop Play; authored
|
||||||
`LevelObject` edits made during PIE **remain** in the scene (the level may show as dirty).
|
`LevelObject` edits made during PIE **remain** in the scene (the level may show as dirty).
|
||||||
- Play mode swaps the unified viewport between the player camera (possessed) and editor fly camera
|
- Play mode swaps the unified viewport between the player camera (possessed) and editor fly camera
|
||||||
@ -323,6 +332,16 @@ identity, project and active-scene paths, dirty flags, aggregate validation coun
|
|||||||
state, and the bounded Scene I/O log. It excludes scene and asset contents, environment values,
|
state, and the bounded Scene I/O log. It excludes scene and asset contents, environment values,
|
||||||
host/user identity, credentials, access tokens, and modal tool state.
|
host/user identity, credentials, access tokens, and modal tool state.
|
||||||
|
|
||||||
|
### External file changes and read-only assets
|
||||||
|
|
||||||
|
The active scene status strip and selected asset header show compact Git/read-only/ownership state
|
||||||
|
when available. If Save or Apply detects a different disk revision, Blacksite leaves that revision
|
||||||
|
untouched and opens **Authored File Not Saved**. Use **Compare Metadata** to inspect both revisions,
|
||||||
|
**Save As** to preserve the editor copy elsewhere, **Reload** to take the disk version, or **Cancel**
|
||||||
|
to keep the local draft unsaved. Git and ownership providers are optional; the status scanner never
|
||||||
|
stages, commits, resets, checks out, restores, or discards files. See
|
||||||
|
[collaborative-file-safety.md](docs/editor/collaborative-file-safety.md).
|
||||||
|
|
||||||
## Cursor / VSCode Setup
|
## Cursor / VSCode Setup
|
||||||
|
|
||||||
The `.vscode/` folder is preconfigured:
|
The `.vscode/` folder is preconfigured:
|
||||||
@ -346,6 +365,7 @@ The `.vscode/` folder is preconfigured:
|
|||||||
- [ADR 0016: Unified Rendering Contract](docs/adr/0016-unified-rendering-contract.md)
|
- [ADR 0016: Unified Rendering Contract](docs/adr/0016-unified-rendering-contract.md)
|
||||||
- [ADR 0017: Normalized Static Mesh Assets](docs/adr/0017-normalized-static-mesh-assets.md)
|
- [ADR 0017: Normalized Static Mesh Assets](docs/adr/0017-normalized-static-mesh-assets.md)
|
||||||
- [ADR 0034: Registry-driven Authoring Components](docs/adr/0034-registry-driven-authoring-components.md)
|
- [ADR 0034: Registry-driven Authoring Components](docs/adr/0034-registry-driven-authoring-components.md)
|
||||||
|
- [ADR 0037: Collaborative Authored-File Safety](docs/adr/0037-collaborative-authored-file-safety.md)
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@ -393,6 +413,7 @@ crates/
|
|||||||
- [x] Audio source/listener authoring, non-dirty spatial audition, viewport icons/range gizmos, stable buses, PIE/runtime parity, device diagnostics, and shared release validation ([ADR 0030](docs/adr/0030-audio-authoring-and-bus-schema.md); production acceptance completed in [Gitea #47](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47))
|
- [x] Audio source/listener authoring, non-dirty spatial audition, viewport icons/range gizmos, stable buses, PIE/runtime parity, device diagnostics, and shared release validation ([ADR 0030](docs/adr/0030-audio-authoring-and-bus-schema.md); production acceptance completed in [Gitea #47](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47))
|
||||||
- [x] glTF/GLB skeletal animation manifests, stable controller states, non-dirty preview, PIE/runtime hydration, and exact-signature compatibility validation ([ADR 0031](docs/adr/0031-animation-authoring-runtime-contract.md); production acceptance completed in [Gitea #46](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/46))
|
- [x] glTF/GLB skeletal animation manifests, stable controller states, non-dirty preview, PIE/runtime hydration, and exact-signature compatibility validation ([ADR 0031](docs/adr/0031-animation-authoring-runtime-contract.md); production acceptance completed in [Gitea #46](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/46))
|
||||||
- [x] Navigation bounds/obstacles/areas/links, persisted validation samples, composed-source resolution, deterministic stale-checked bake artifacts, viewport path preview, headless bake, and shared game/runtime query API ([ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md); [evaluation](docs/editor/evaluations/navigation-authoring/); [Gitea #48](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48))
|
- [x] Navigation bounds/obstacles/areas/links, persisted validation samples, composed-source resolution, deterministic stale-checked bake artifacts, viewport path preview, headless bake, and shared game/runtime query API ([ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md); [evaluation](docs/editor/evaluations/navigation-authoring/); [Gitea #48](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48))
|
||||||
|
- [x] Exact authored-file revision guards for scenes, prefab source Apply/history, Materials, Material Instances, and Project Settings; compact observational Git/read-only/ownership state; and explicit Reload/Compare Metadata/Save As/Cancel recovery without force overwrite ([ADR 0037](docs/adr/0037-collaborative-authored-file-safety.md), [collaboration guide](docs/editor/collaborative-file-safety.md), [Gitea #49](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/49))
|
||||||
- [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack
|
- [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack
|
||||||
- [x] Independent dirty-tab close confirmation and all-tab save guard when switching projects
|
- [x] Independent dirty-tab close confirmation and all-tab save guard when switching projects
|
||||||
- [x] Transactional scene writes + bounded user-local recovery snapshots ([ADR 0023](docs/adr/0023-transactional-scene-persistence-and-recovery.md))
|
- [x] Transactional scene writes + bounded user-local recovery snapshots ([ADR 0023](docs/adr/0023-transactional-scene-persistence-and-recovery.md))
|
||||||
|
|||||||
@ -1248,6 +1248,10 @@ fn apply_prefab_scope_to_source(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let before_snapshot = crate::collaboration::FileSnapshot::from_loaded_bytes(
|
||||||
|
&source_path,
|
||||||
|
before_source.as_bytes(),
|
||||||
|
);
|
||||||
let mut document = match SceneDocument::from_ron_text(&before_source) {
|
let mut document = match SceneDocument::from_ron_text(&before_source) {
|
||||||
Ok(document) => document,
|
Ok(document) => document,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@ -1361,7 +1365,13 @@ fn apply_prefab_scope_to_source(
|
|||||||
}
|
}
|
||||||
let after_source = document.to_ron_text()?;
|
let after_source = document.to_ron_text()?;
|
||||||
scene::validate_prefab_graph_text(&after_source, &source_path, &project_root)?;
|
scene::validate_prefab_graph_text(&after_source, &source_path, &project_root)?;
|
||||||
crate::scene::recovery::atomic_write(&source_path, after_source.as_bytes())?;
|
crate::collaboration::publish_authored_file(
|
||||||
|
world,
|
||||||
|
&source_path,
|
||||||
|
after_source.as_bytes(),
|
||||||
|
&before_snapshot,
|
||||||
|
crate::collaboration::FileWriteIntent::PrefabSource { instance_root },
|
||||||
|
)?;
|
||||||
Ok::<_, String>(after_source)
|
Ok::<_, String>(after_source)
|
||||||
})();
|
})();
|
||||||
let after_source = match apply_result {
|
let after_source = match apply_result {
|
||||||
@ -2607,6 +2617,22 @@ fn retry_prefab_source(world: &mut World, entity: Entity) {
|
|||||||
"Prefab source queued for reload".into();
|
"Prefab source queued for reload".into();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reload_prefab_source_after_file_conflict(
|
||||||
|
world: &mut World,
|
||||||
|
instance_root: Entity,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if world.get::<PrefabInstance>(instance_root).is_none() {
|
||||||
|
return Err("the prefab instance no longer exists".into());
|
||||||
|
}
|
||||||
|
retry_prefab_source(world, instance_root);
|
||||||
|
let status = world.resource::<crate::scene_io::SceneIo>().status.clone();
|
||||||
|
if status.starts_with("Prefab retry failed") {
|
||||||
|
Err(status)
|
||||||
|
} else {
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn prefab_member_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
pub fn prefab_member_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||||
let Some((instance_root, target)) = prefab_member_override_target(world, entity) else {
|
let Some((instance_root, target)) = prefab_member_override_target(world, entity) else {
|
||||||
ui.label(egui::RichText::new("Prefab target unavailable").color(ERROR));
|
ui.label(egui::RichText::new("Prefab target unavailable").color(ERROR));
|
||||||
@ -3258,6 +3284,38 @@ mod tests {
|
|||||||
assert_eq!(read_overrides(&instance), PrefabOverrides::default());
|
assert_eq!(read_overrides(&instance), PrefabOverrides::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collaboration_reload_restarts_prefab_hydration_without_dropping_overrides() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"blacksite-prefab-collaboration-reload-{}",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let source_path = root.join("assets/prefabs/base.scn.ron");
|
||||||
|
std::fs::create_dir_all(source_path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(&source_path, prefab_source("mesh", None)).unwrap();
|
||||||
|
let mut world = prefab_test_world(&root);
|
||||||
|
let mut instance = PrefabInstance::new("base", "assets/prefabs/base.scn.ron");
|
||||||
|
instance.overrides_ron = encode_prefab_overrides(&PrefabOverrides {
|
||||||
|
source_revision: Some("previous".into()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let instance_root = world.spawn((instance.clone(), HydratedPrefabReady)).id();
|
||||||
|
|
||||||
|
let status = reload_prefab_source_after_file_conflict(&mut world, instance_root).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(status, "Prefab source queued for reload");
|
||||||
|
assert_eq!(
|
||||||
|
world
|
||||||
|
.get::<PrefabInstance>(instance_root)
|
||||||
|
.map(|current| ¤t.overrides_ron),
|
||||||
|
Some(&instance.overrides_ron)
|
||||||
|
);
|
||||||
|
assert!(world.get::<HydratedPrefabReady>(instance_root).is_none());
|
||||||
|
assert!(world.get::<PrefabRef>(instance_root).is_some());
|
||||||
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nested_unpack_fold_is_transactional_when_a_later_link_is_invalid() {
|
fn nested_unpack_fold_is_transactional_when_a_later_link_is_invalid() {
|
||||||
let mut world = World::new();
|
let mut world = World::new();
|
||||||
|
|||||||
@ -1570,7 +1570,7 @@ pub fn set_scene_composition_with_history(world: &mut World, new: SceneCompositi
|
|||||||
pub fn apply_command_undo(world: &mut World) {
|
pub fn apply_command_undo(world: &mut World) {
|
||||||
let candidate = world.resource::<EditorHistory>().undo_stack.last().cloned();
|
let candidate = world.resource::<EditorHistory>().undo_stack.last().cloned();
|
||||||
if let Some(candidate) = candidate {
|
if let Some(candidate) = candidate {
|
||||||
if let Err(error) = prepare_prefab_source_history(&candidate, true) {
|
if let Err(error) = prepare_prefab_source_history(world, &candidate, true) {
|
||||||
world.resource_mut::<EditorHistory>().status = format!("Undo blocked: {error}");
|
world.resource_mut::<EditorHistory>().status = format!("Undo blocked: {error}");
|
||||||
world
|
world
|
||||||
.resource_mut::<SceneIo>()
|
.resource_mut::<SceneIo>()
|
||||||
@ -1591,7 +1591,7 @@ pub fn apply_command_undo(world: &mut World) {
|
|||||||
pub fn apply_command_redo(world: &mut World) {
|
pub fn apply_command_redo(world: &mut World) {
|
||||||
let candidate = world.resource::<EditorHistory>().redo_stack.last().cloned();
|
let candidate = world.resource::<EditorHistory>().redo_stack.last().cloned();
|
||||||
if let Some(candidate) = candidate {
|
if let Some(candidate) = candidate {
|
||||||
if let Err(error) = prepare_prefab_source_history(&candidate, false) {
|
if let Err(error) = prepare_prefab_source_history(world, &candidate, false) {
|
||||||
world.resource_mut::<EditorHistory>().status = format!("Redo blocked: {error}");
|
world.resource_mut::<EditorHistory>().status = format!("Redo blocked: {error}");
|
||||||
world
|
world
|
||||||
.resource_mut::<SceneIo>()
|
.resource_mut::<SceneIo>()
|
||||||
@ -1609,8 +1609,13 @@ pub fn apply_command_redo(world: &mut World) {
|
|||||||
mark_dirty(world);
|
mark_dirty(world);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_prefab_source_history(command: &EditorCommand, undo: bool) -> Result<(), String> {
|
fn prepare_prefab_source_history(
|
||||||
|
world: &mut World,
|
||||||
|
command: &EditorCommand,
|
||||||
|
undo: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
let EditorCommand::ApplyPrefabToSource {
|
let EditorCommand::ApplyPrefabToSource {
|
||||||
|
entity,
|
||||||
source_path,
|
source_path,
|
||||||
before_source,
|
before_source,
|
||||||
after_source,
|
after_source,
|
||||||
@ -1624,15 +1629,18 @@ fn prepare_prefab_source_history(command: &EditorCommand, undo: bool) -> Result<
|
|||||||
} else {
|
} else {
|
||||||
(before_source, after_source)
|
(before_source, after_source)
|
||||||
};
|
};
|
||||||
let current = std::fs::read_to_string(source_path)
|
let expected =
|
||||||
.map_err(|error| format!("could not read {}: {error}", source_path.display()))?;
|
crate::collaboration::FileSnapshot::from_loaded_bytes(source_path, expected.as_bytes());
|
||||||
if ¤t != expected {
|
crate::collaboration::publish_authored_file(
|
||||||
return Err(format!(
|
world,
|
||||||
"{} changed outside this history command",
|
source_path,
|
||||||
source_path.display()
|
replacement.as_bytes(),
|
||||||
));
|
&expected,
|
||||||
}
|
crate::collaboration::FileWriteIntent::PrefabSourceHistory {
|
||||||
crate::scene::recovery::atomic_write(source_path, replacement.as_bytes())
|
instance_root: *entity,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn undo_command(world: &mut World, command: &mut EditorCommand) {
|
fn undo_command(world: &mut World, command: &mut EditorCommand) {
|
||||||
|
|||||||
@ -22,6 +22,7 @@ pub use ext::extensibility;
|
|||||||
pub use ext::hot_reload;
|
pub use ext::hot_reload;
|
||||||
pub use play::net_editor;
|
pub use play::net_editor;
|
||||||
pub use play::state;
|
pub use play::state;
|
||||||
|
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::project_io;
|
pub use project::project_io;
|
||||||
@ -64,6 +65,7 @@ use infra::EditorInfraPlugin;
|
|||||||
use operators::OperatorPlugin;
|
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_io::ProjectIoPlugin;
|
use project_io::ProjectIoPlugin;
|
||||||
use render_view::RenderViewPlugin;
|
use render_view::RenderViewPlugin;
|
||||||
use scene_io::SceneIoPlugin;
|
use scene_io::SceneIoPlugin;
|
||||||
@ -111,6 +113,7 @@ impl PluginGroup for EditorPluginGroup {
|
|||||||
.add(EditorVisualizersPlugin)
|
.add(EditorVisualizersPlugin)
|
||||||
.add(EditorHistoryPlugin)
|
.add(EditorHistoryPlugin)
|
||||||
.add(SceneIoPlugin)
|
.add(SceneIoPlugin)
|
||||||
|
.add(CollaborationPlugin)
|
||||||
.add(EditorUiPlugin)
|
.add(EditorUiPlugin)
|
||||||
.add(EditorSessionPlugin)
|
.add(EditorSessionPlugin)
|
||||||
.add(brp::BrpPlugin);
|
.add(brp::BrpPlugin);
|
||||||
|
|||||||
1731
crates/editor/src/project/collaboration.rs
Normal file
1731
crates/editor/src/project/collaboration.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
//! Project workspace I/O, settings UI, and recent-level sync.
|
//! Project workspace I/O, settings UI, and recent-level sync.
|
||||||
|
|
||||||
|
pub mod collaboration;
|
||||||
pub mod diagnostics_bundle;
|
pub mod diagnostics_bundle;
|
||||||
pub mod launcher;
|
pub mod launcher;
|
||||||
pub mod project_io;
|
pub mod project_io;
|
||||||
|
|||||||
@ -1,18 +1,28 @@
|
|||||||
//! Editor-only Project Settings panel (egui). Data lives in the `settings` crate.
|
//! Editor-only Project Settings panel (egui). Data lives in the `settings` crate.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_egui::egui;
|
use bevy_egui::egui;
|
||||||
use game_hot::sync_project_sun_from_settings;
|
use game_hot::sync_project_sun_from_settings;
|
||||||
use settings::{
|
use settings::{
|
||||||
load_project_settings_from_path, save_project_settings, sync_sim_tuning, ExposureMode,
|
save_project_settings_to_string, sync_sim_tuning, ExposureMode, ProjectSettings,
|
||||||
ProjectSettings, ProjectSettingsChanged, ProjectSettingsIo,
|
ProjectSettingsChanged, ProjectSettingsIo,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::project::collaboration::{
|
||||||
|
file_status_indicator_ui, publish_authored_file, CollaborationState, FileSnapshot,
|
||||||
|
FileWriteIntent,
|
||||||
|
};
|
||||||
|
use crate::scene_io::SceneIo;
|
||||||
|
|
||||||
/// Whether the Project Settings window is visible (editor UI state only).
|
/// Whether the Project Settings window is visible (editor UI state only).
|
||||||
#[derive(Resource, Debug, Default)]
|
#[derive(Resource, Debug, Default)]
|
||||||
pub struct ProjectSettingsPanel {
|
pub struct ProjectSettingsPanel {
|
||||||
pub open: bool,
|
pub open: bool,
|
||||||
pub draft: Option<ProjectSettings>,
|
pub draft: Option<ProjectSettings>,
|
||||||
|
disk_snapshot: Option<FileSnapshot>,
|
||||||
|
save_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SettingsUiPlugin;
|
pub struct SettingsUiPlugin;
|
||||||
@ -39,11 +49,23 @@ pub fn project_settings_window(
|
|||||||
) {
|
) {
|
||||||
if !panel.open {
|
if !panel.open {
|
||||||
panel.draft = None;
|
panel.draft = None;
|
||||||
|
panel.disk_snapshot = None;
|
||||||
|
panel.save_error = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if panel.draft.is_none() {
|
if panel.draft.is_none() {
|
||||||
panel.draft = Some(world.resource::<ProjectSettings>().clone());
|
panel.draft = Some(world.resource::<ProjectSettings>().clone());
|
||||||
|
let io = world.resource::<ProjectSettingsIo>();
|
||||||
|
panel.disk_snapshot = Some(
|
||||||
|
io.loaded_source
|
||||||
|
.as_deref()
|
||||||
|
.map(|source| {
|
||||||
|
FileSnapshot::from_loaded_bytes(Path::new(&io.path), source.as_bytes())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(FileSnapshot::missing),
|
||||||
|
);
|
||||||
|
panel.save_error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut open = panel.open;
|
let mut open = panel.open;
|
||||||
@ -51,17 +73,33 @@ pub fn project_settings_window(
|
|||||||
.open(&mut open)
|
.open(&mut open)
|
||||||
.default_width(420.0)
|
.default_width(420.0)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
let Some(draft) = panel.draft.as_mut() else {
|
let ProjectSettingsPanel {
|
||||||
|
draft: Some(draft),
|
||||||
|
disk_snapshot,
|
||||||
|
save_error,
|
||||||
|
..
|
||||||
|
} = panel
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let io_path = world.resource::<ProjectSettingsIo>().path.clone();
|
let io_path = world.resource::<ProjectSettingsIo>().path.clone();
|
||||||
let mut io_dirty = world.resource::<ProjectSettingsIo>().dirty;
|
let mut io_dirty = world.resource::<ProjectSettingsIo>().dirty;
|
||||||
draw_project_settings_form(world, ui, draft, &io_path, &mut io_dirty);
|
draw_project_settings_form(
|
||||||
|
world,
|
||||||
|
ui,
|
||||||
|
draft,
|
||||||
|
&io_path,
|
||||||
|
&mut io_dirty,
|
||||||
|
disk_snapshot,
|
||||||
|
save_error,
|
||||||
|
);
|
||||||
world.resource_mut::<ProjectSettingsIo>().dirty = io_dirty;
|
world.resource_mut::<ProjectSettingsIo>().dirty = io_dirty;
|
||||||
});
|
});
|
||||||
panel.open = open;
|
panel.open = open;
|
||||||
if !open {
|
if !open {
|
||||||
panel.draft = None;
|
panel.draft = None;
|
||||||
|
panel.disk_snapshot = None;
|
||||||
|
panel.save_error = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,12 +109,26 @@ fn draw_project_settings_form(
|
|||||||
draft: &mut ProjectSettings,
|
draft: &mut ProjectSettings,
|
||||||
io_path: &str,
|
io_path: &str,
|
||||||
io_dirty: &mut bool,
|
io_dirty: &mut bool,
|
||||||
|
disk_snapshot: &mut Option<FileSnapshot>,
|
||||||
|
save_error: &mut Option<String>,
|
||||||
) {
|
) {
|
||||||
let caps = world
|
let caps = world
|
||||||
.get_resource::<settings::RenderingCapabilities>()
|
.get_resource::<settings::RenderingCapabilities>()
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.small(egui::RichText::new(io_path).monospace());
|
||||||
|
if let Some(state) = world.get_resource::<CollaborationState>() {
|
||||||
|
let status = state.file_status(Path::new(io_path));
|
||||||
|
let _ = file_status_indicator_ui(ui, &status, Path::new(io_path));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(error) = save_error.as_deref() {
|
||||||
|
ui.colored_label(crate::ui::theme::ERROR, error);
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
ui.collapsing("Global Illumination", |ui| {
|
ui.collapsing("Global Illumination", |ui| {
|
||||||
egui::ComboBox::from_label("GiMode")
|
egui::ComboBox::from_label("GiMode")
|
||||||
.selected_text(format!("{:?}", draft.rendering.gi_mode))
|
.selected_text(format!("{:?}", draft.rendering.gi_mode))
|
||||||
@ -335,25 +387,116 @@ fn draw_project_settings_form(
|
|||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
commit_project_settings(world, draft);
|
commit_project_settings(world, draft);
|
||||||
|
*io_dirty = true;
|
||||||
let settings = world.resource::<ProjectSettings>().clone();
|
let settings = world.resource::<ProjectSettings>().clone();
|
||||||
|
let Some(expected) = disk_snapshot.clone() else {
|
||||||
|
let error = "Project settings disk baseline is unavailable; reload before saving";
|
||||||
|
*save_error = Some(error.into());
|
||||||
|
world.resource_mut::<SceneIo>().set_status(error);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match persist_project_settings(world, &settings, &expected) {
|
||||||
|
Ok((snapshot, source)) => {
|
||||||
let mut io = world.resource_mut::<ProjectSettingsIo>();
|
let mut io = world.resource_mut::<ProjectSettingsIo>();
|
||||||
if let Err(error) = save_project_settings(&settings, &mut io) {
|
io.dirty = false;
|
||||||
warn!("Save project settings failed: {error}");
|
io.loaded_source = Some(source);
|
||||||
} else {
|
*disk_snapshot = Some(snapshot);
|
||||||
|
*save_error = None;
|
||||||
*io_dirty = false;
|
*io_dirty = false;
|
||||||
|
world
|
||||||
|
.resource_mut::<SceneIo>()
|
||||||
|
.set_status(format!("Saved project settings to {io_path}"));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
warn!("Save project settings failed: {error}");
|
||||||
|
*save_error = Some(error.clone());
|
||||||
|
world
|
||||||
|
.resource_mut::<SceneIo>()
|
||||||
|
.set_status(format!("Save project settings failed: {error}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ui.button("Revert from disk").clicked() {
|
if ui.button("Revert from disk").clicked() {
|
||||||
*draft = load_project_settings_from_path(io_path);
|
match read_project_settings_document(Path::new(io_path)) {
|
||||||
|
Ok((settings, source, snapshot)) => {
|
||||||
|
*draft = settings;
|
||||||
commit_project_settings(world, draft);
|
commit_project_settings(world, draft);
|
||||||
world.resource_mut::<ProjectSettingsIo>().dirty = false;
|
let mut io = world.resource_mut::<ProjectSettingsIo>();
|
||||||
|
io.dirty = false;
|
||||||
|
io.loaded_source = Some(source);
|
||||||
|
*disk_snapshot = Some(snapshot);
|
||||||
|
*save_error = None;
|
||||||
*io_dirty = false;
|
*io_dirty = false;
|
||||||
}
|
}
|
||||||
|
Err(error) => {
|
||||||
|
*save_error = Some(error.clone());
|
||||||
|
world
|
||||||
|
.resource_mut::<SceneIo>()
|
||||||
|
.set_status(format!("Revert project settings failed: {error}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if *io_dirty {
|
if *io_dirty {
|
||||||
ui.label(egui::RichText::new("Unsaved changes").color(egui::Color32::YELLOW));
|
ui.label(egui::RichText::new("Unsaved changes").color(egui::Color32::YELLOW));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persist_project_settings(
|
||||||
|
world: &mut World,
|
||||||
|
settings: &ProjectSettings,
|
||||||
|
expected: &FileSnapshot,
|
||||||
|
) -> Result<(FileSnapshot, String), String> {
|
||||||
|
let path = world.resource::<ProjectSettingsIo>().path.clone();
|
||||||
|
let source = save_project_settings_to_string(settings).map_err(|error| error.to_string())?;
|
||||||
|
let snapshot = publish_authored_file(
|
||||||
|
world,
|
||||||
|
Path::new(&path),
|
||||||
|
source.as_bytes(),
|
||||||
|
expected,
|
||||||
|
FileWriteIntent::ProjectSettings,
|
||||||
|
)?;
|
||||||
|
Ok((snapshot, source))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_project_settings_document(
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(ProjectSettings, String, FileSnapshot), String> {
|
||||||
|
let source = std::fs::read_to_string(path)
|
||||||
|
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
|
||||||
|
let settings: ProjectSettings = ron::from_str(&source)
|
||||||
|
.map_err(|error| format!("invalid project settings in {}: {error}", path.display()))?;
|
||||||
|
let snapshot = FileSnapshot::from_loaded_bytes(path, source.as_bytes());
|
||||||
|
Ok((settings, source, snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reload_project_settings_after_file_conflict(
|
||||||
|
world: &mut World,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let active_path = world.resource::<ProjectSettingsIo>().path.clone();
|
||||||
|
if Path::new(&active_path) != path {
|
||||||
|
return Err("the project settings conflict no longer matches the active manifest".into());
|
||||||
|
}
|
||||||
|
let (settings, source, snapshot) = read_project_settings_document(path)?;
|
||||||
|
commit_project_settings(world, &settings);
|
||||||
|
{
|
||||||
|
let mut io = world.resource_mut::<ProjectSettingsIo>();
|
||||||
|
io.dirty = false;
|
||||||
|
io.loaded_source = Some(source);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut panel = world.resource_mut::<ProjectSettingsPanel>();
|
||||||
|
panel.draft = Some(settings);
|
||||||
|
panel.disk_snapshot = Some(snapshot);
|
||||||
|
panel.save_error = None;
|
||||||
|
}
|
||||||
|
Ok(format!("Reloaded project settings from {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn project_settings_conflict_copy_saved(world: &mut World) {
|
||||||
|
world.resource_mut::<ProjectSettingsPanel>().save_error = None;
|
||||||
|
}
|
||||||
|
|
||||||
fn commit_project_settings(world: &mut World, draft: &ProjectSettings) {
|
fn commit_project_settings(world: &mut World, draft: &ProjectSettings) {
|
||||||
let previous = world.resource::<ProjectSettings>().rendering.gi_mode;
|
let previous = world.resource::<ProjectSettings>().rendering.gi_mode;
|
||||||
*world.resource_mut::<ProjectSettings>() = draft.clone();
|
*world.resource_mut::<ProjectSettings>() = draft.clone();
|
||||||
@ -406,3 +549,35 @@ fn apply_project_settings_changes(
|
|||||||
// Full stack refresh runs next frame via `render_view::sync_project_render_view`
|
// Full stack refresh runs next frame via `render_view::sync_project_render_view`
|
||||||
// when `ProjectSettings` changes.
|
// when `ProjectSettings` changes.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_settings_save_refuses_an_external_revision() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"blacksite-project-settings-external-write-{}",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let path = root.join("assets/project.ron");
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
let loaded = save_project_settings_to_string(&ProjectSettings::default()).unwrap();
|
||||||
|
std::fs::write(&path, &loaded).unwrap();
|
||||||
|
let expected = FileSnapshot::from_loaded_bytes(&path, loaded.as_bytes());
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(ProjectSettingsIo {
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
loaded_source: Some(loaded),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
world.insert_resource(SceneIo::default());
|
||||||
|
std::fs::write(&path, b"external").unwrap();
|
||||||
|
|
||||||
|
let result = persist_project_settings(&mut world, &ProjectSettings::default(), &expected);
|
||||||
|
|
||||||
|
assert!(result.unwrap_err().contains("changed outside Blacksite"));
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"external");
|
||||||
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -13,17 +13,20 @@ pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
|||||||
atomic_write_with_pre_rename(path, bytes, || Ok(()))
|
atomic_write_with_pre_rename(path, bytes, || Ok(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn atomic_write_with_pre_rename(
|
pub(crate) fn atomic_write_with_pre_rename<E>(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
before_rename: impl FnOnce() -> Result<(), String>,
|
before_rename: impl FnOnce() -> Result<(), E>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), E>
|
||||||
|
where
|
||||||
|
E: From<String>,
|
||||||
|
{
|
||||||
let parent = path
|
let parent = path
|
||||||
.parent()
|
.parent()
|
||||||
.filter(|parent| !parent.as_os_str().is_empty())
|
.filter(|parent| !parent.as_os_str().is_empty())
|
||||||
.unwrap_or_else(|| Path::new("."));
|
.unwrap_or_else(|| Path::new("."));
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
.map_err(|error| format!("could not create {}: {error}", parent.display()))?;
|
.map_err(|error| E::from(format!("could not create {}: {error}", parent.display())))?;
|
||||||
|
|
||||||
let file_name = path
|
let file_name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
@ -40,18 +43,21 @@ fn atomic_write_with_pre_rename(
|
|||||||
.create_new(true)
|
.create_new(true)
|
||||||
.write(true)
|
.write(true)
|
||||||
.open(&temporary)
|
.open(&temporary)
|
||||||
.map_err(|error| format!("could not create {}: {error}", temporary.display()))?;
|
.map_err(|error| {
|
||||||
file.write_all(bytes)
|
E::from(format!("could not create {}: {error}", temporary.display()))
|
||||||
.map_err(|error| format!("could not write {}: {error}", temporary.display()))?;
|
})?;
|
||||||
|
file.write_all(bytes).map_err(|error| {
|
||||||
|
E::from(format!("could not write {}: {error}", temporary.display()))
|
||||||
|
})?;
|
||||||
file.sync_all()
|
file.sync_all()
|
||||||
.map_err(|error| format!("could not sync {}: {error}", temporary.display()))?;
|
.map_err(|error| E::from(format!("could not sync {}: {error}", temporary.display())))?;
|
||||||
before_rename()?;
|
before_rename()?;
|
||||||
fs::rename(&temporary, path).map_err(|error| {
|
fs::rename(&temporary, path).map_err(|error| {
|
||||||
format!(
|
E::from(format!(
|
||||||
"could not replace {} with {}: {error}",
|
"could not replace {} with {}: {error}",
|
||||||
path.display(),
|
path.display(),
|
||||||
temporary.display()
|
temporary.display()
|
||||||
)
|
))
|
||||||
})?;
|
})?;
|
||||||
sync_parent_directory(parent);
|
sync_parent_directory(parent);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -23,8 +23,9 @@ use shared::{
|
|||||||
|
|
||||||
use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS};
|
use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS};
|
||||||
use crate::history::{clear_level_objects, snapshot_entity, EditorHistory};
|
use crate::history::{clear_level_objects, snapshot_entity, EditorHistory};
|
||||||
|
use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent};
|
||||||
use crate::scene::recovery::{
|
use crate::scene::recovery::{
|
||||||
atomic_write, default_state_root, discard_recovery_snapshots, latest_recovery_snapshot,
|
default_state_root, discard_recovery_snapshots, latest_recovery_snapshot,
|
||||||
write_recovery_snapshot,
|
write_recovery_snapshot,
|
||||||
};
|
};
|
||||||
use crate::selection::SelectedEntity;
|
use crate::selection::SelectedEntity;
|
||||||
@ -60,6 +61,7 @@ pub struct SceneTab {
|
|||||||
pub dirty: bool,
|
pub dirty: bool,
|
||||||
snapshot: String,
|
snapshot: String,
|
||||||
recovery_snapshot: Option<PathBuf>,
|
recovery_snapshot: Option<PathBuf>,
|
||||||
|
disk_snapshot: Option<FileSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SceneTab {
|
impl SceneTab {
|
||||||
@ -126,6 +128,7 @@ impl Default for SceneIo {
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
snapshot: String::new(),
|
snapshot: String::new(),
|
||||||
recovery_snapshot: None,
|
recovery_snapshot: None,
|
||||||
|
disk_snapshot: None,
|
||||||
}],
|
}],
|
||||||
active_tab: 0,
|
active_tab: 0,
|
||||||
change_revision: 0,
|
change_revision: 0,
|
||||||
@ -252,9 +255,14 @@ fn load_startup_scene(world: &mut World) {
|
|||||||
let path = active_path.unwrap_or(default_path);
|
let path = active_path.unwrap_or(default_path);
|
||||||
|
|
||||||
match load_level(world, &path) {
|
match load_level(world, &path) {
|
||||||
Ok(()) => {
|
Ok(disk_snapshot) => {
|
||||||
world.resource_mut::<SceneIo>().active_path = Some(path.clone());
|
{
|
||||||
world.resource_mut::<SceneIo>().mark_clean();
|
let mut io = world.resource_mut::<SceneIo>();
|
||||||
|
io.active_path = Some(path.clone());
|
||||||
|
let active_tab = io.active_tab;
|
||||||
|
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
|
||||||
|
io.mark_clean();
|
||||||
|
}
|
||||||
remember_path(world, path.clone());
|
remember_path(world, path.clone());
|
||||||
world.resource_mut::<EditorHistory>().clear();
|
world.resource_mut::<EditorHistory>().clear();
|
||||||
refresh_recovery_notice(world, &path);
|
refresh_recovery_notice(world, &path);
|
||||||
@ -396,7 +404,7 @@ fn open_path(world: &mut World, path: PathBuf) -> String {
|
|||||||
return format!("Open failed: could not preserve active scene: {error}");
|
return format!("Open failed: could not preserve active scene: {error}");
|
||||||
}
|
}
|
||||||
match load_level(world, &path) {
|
match load_level(world, &path) {
|
||||||
Ok(()) => {
|
Ok(disk_snapshot) => {
|
||||||
let id = world.resource_mut::<SceneIo>().allocate_tab_id();
|
let id = world.resource_mut::<SceneIo>().allocate_tab_id();
|
||||||
{
|
{
|
||||||
let mut io = world.resource_mut::<SceneIo>();
|
let mut io = world.resource_mut::<SceneIo>();
|
||||||
@ -406,6 +414,7 @@ fn open_path(world: &mut World, path: PathBuf) -> String {
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
snapshot: String::new(),
|
snapshot: String::new(),
|
||||||
recovery_snapshot: None,
|
recovery_snapshot: None,
|
||||||
|
disk_snapshot: Some(disk_snapshot),
|
||||||
});
|
});
|
||||||
io.active_tab = io.tabs.len() - 1;
|
io.active_tab = io.tabs.len() - 1;
|
||||||
io.active_path = Some(path.clone());
|
io.active_path = Some(path.clone());
|
||||||
@ -442,6 +451,7 @@ fn new_scene_tab(world: &mut World) -> String {
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
snapshot: String::new(),
|
snapshot: String::new(),
|
||||||
recovery_snapshot: None,
|
recovery_snapshot: None,
|
||||||
|
disk_snapshot: None,
|
||||||
});
|
});
|
||||||
io.active_tab = io.tabs.len() - 1;
|
io.active_tab = io.tabs.len() - 1;
|
||||||
io.active_path = None;
|
io.active_path = None;
|
||||||
@ -550,6 +560,7 @@ fn close_scene_tab(world: &mut World, index: usize) -> String {
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
snapshot: String::new(),
|
snapshot: String::new(),
|
||||||
recovery_snapshot: None,
|
recovery_snapshot: None,
|
||||||
|
disk_snapshot: None,
|
||||||
}];
|
}];
|
||||||
io.active_tab = 0;
|
io.active_tab = 0;
|
||||||
io.active_path = None;
|
io.active_path = None;
|
||||||
@ -691,12 +702,24 @@ fn save_selection_as_prefab(world: &mut World) -> String {
|
|||||||
else {
|
else {
|
||||||
return "Save prefab cancelled".to_string();
|
return "Save prefab cancelled".to_string();
|
||||||
};
|
};
|
||||||
|
let expected = match FileSnapshot::capture(&path) {
|
||||||
|
Ok(expected) => expected,
|
||||||
|
Err(error) => return format!("Save prefab failed: {error}"),
|
||||||
|
};
|
||||||
|
|
||||||
if ensure_unique_actor_ids(world, &selection) > 0 {
|
if ensure_unique_actor_ids(world, &selection) > 0 {
|
||||||
world.resource_mut::<SceneIo>().mark_dirty();
|
world.resource_mut::<SceneIo>().mark_dirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
match save_prefab_entities(world, &path, selection) {
|
match save_prefab_entities(
|
||||||
|
world,
|
||||||
|
&path,
|
||||||
|
selection,
|
||||||
|
SceneWriteContext::Standalone {
|
||||||
|
expected,
|
||||||
|
description: "prefab copy",
|
||||||
|
},
|
||||||
|
) {
|
||||||
Ok(count) => {
|
Ok(count) => {
|
||||||
world.resource_mut::<EditorAssets>().refresh();
|
world.resource_mut::<EditorAssets>().refresh();
|
||||||
format!(
|
format!(
|
||||||
@ -775,6 +798,7 @@ fn save_prefab_entities(
|
|||||||
world: &mut World,
|
world: &mut World,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
entities: Vec<Entity>,
|
entities: Vec<Entity>,
|
||||||
|
write_context: SceneWriteContext,
|
||||||
) -> Result<usize, String> {
|
) -> Result<usize, String> {
|
||||||
let count = entities.len();
|
let count = entities.len();
|
||||||
let original_actor_ids: Vec<_> = entities
|
let original_actor_ids: Vec<_> = entities
|
||||||
@ -791,7 +815,7 @@ fn save_prefab_entities(
|
|||||||
.clone(),
|
.clone(),
|
||||||
);
|
);
|
||||||
scene::validate_prefab_graph_text(&text, path, &project_root)?;
|
scene::validate_prefab_graph_text(&text, path, &project_root)?;
|
||||||
atomic_write(path, text.as_bytes())?;
|
publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
|
||||||
Ok(count)
|
Ok(count)
|
||||||
})();
|
})();
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
@ -821,7 +845,7 @@ pub fn save_active_or_prompt_world(world: &mut World) -> String {
|
|||||||
fn save_active_or_prompt(world: &mut World) -> String {
|
fn save_active_or_prompt(world: &mut World) -> String {
|
||||||
let path = world.resource::<SceneIo>().active_path.clone();
|
let path = world.resource::<SceneIo>().active_path.clone();
|
||||||
match path {
|
match path {
|
||||||
Some(path) => match save_level(world, &path) {
|
Some(path) => match save_level(world, &path, SceneWriteContext::Active) {
|
||||||
Ok(count) => {
|
Ok(count) => {
|
||||||
world.resource_mut::<SceneIo>().mark_clean();
|
world.resource_mut::<SceneIo>().mark_clean();
|
||||||
remember_path(world, path.clone());
|
remember_path(world, path.clone());
|
||||||
@ -843,8 +867,12 @@ fn save_with_dialog(world: &mut World) -> String {
|
|||||||
else {
|
else {
|
||||||
return "Save cancelled".to_string();
|
return "Save cancelled".to_string();
|
||||||
};
|
};
|
||||||
|
let expected = match FileSnapshot::capture(&path) {
|
||||||
|
Ok(expected) => expected,
|
||||||
|
Err(error) => return format!("Save failed: {error}"),
|
||||||
|
};
|
||||||
|
|
||||||
match save_level(world, &path) {
|
match save_level(world, &path, SceneWriteContext::ActiveSaveAs { expected }) {
|
||||||
Ok(count) => {
|
Ok(count) => {
|
||||||
world.resource_mut::<SceneIo>().active_path = Some(path.clone());
|
world.resource_mut::<SceneIo>().active_path = Some(path.clone());
|
||||||
world.resource_mut::<SceneIo>().mark_clean();
|
world.resource_mut::<SceneIo>().mark_clean();
|
||||||
@ -901,8 +929,20 @@ fn export_selection_with_dialog(world: &mut World) -> String {
|
|||||||
else {
|
else {
|
||||||
return "Export cancelled".to_string();
|
return "Export cancelled".to_string();
|
||||||
};
|
};
|
||||||
|
let expected = match FileSnapshot::capture(&path) {
|
||||||
|
Ok(expected) => expected,
|
||||||
|
Err(error) => return format!("Export failed: {error}"),
|
||||||
|
};
|
||||||
|
|
||||||
match save_standalone_entities(world, &path, vec![entity]) {
|
match save_standalone_entities(
|
||||||
|
world,
|
||||||
|
&path,
|
||||||
|
vec![entity],
|
||||||
|
SceneWriteContext::Standalone {
|
||||||
|
expected,
|
||||||
|
description: "selection export",
|
||||||
|
},
|
||||||
|
) {
|
||||||
Ok(count) => format!("Exported {count} selected entity to {}", path.display()),
|
Ok(count) => format!("Exported {count} selected entity to {}", path.display()),
|
||||||
Err(err) => format!("Export failed: {err}"),
|
Err(err) => format!("Export failed: {err}"),
|
||||||
}
|
}
|
||||||
@ -934,12 +974,16 @@ fn clear_scene_world(world: &mut World) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_level(world: &mut World, path: &Path) -> Result<usize, String> {
|
fn save_level(
|
||||||
|
world: &mut World,
|
||||||
|
path: &Path,
|
||||||
|
write_context: SceneWriteContext,
|
||||||
|
) -> Result<usize, String> {
|
||||||
let entities = authored_scene_entities(world);
|
let entities = authored_scene_entities(world);
|
||||||
if is_prefab_document(path) {
|
if is_prefab_document(path) {
|
||||||
save_prefab_entities(world, path, entities)
|
save_prefab_entities(world, path, entities, write_context)
|
||||||
} else {
|
} else {
|
||||||
save_entities(world, path, entities)
|
save_entities(world, path, entities, write_context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1131,10 +1175,15 @@ fn format_actor_validation(err: ActorValidationError) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_entities(world: &mut World, path: &Path, entities: Vec<Entity>) -> Result<usize, String> {
|
fn save_entities(
|
||||||
|
world: &mut World,
|
||||||
|
path: &Path,
|
||||||
|
entities: Vec<Entity>,
|
||||||
|
write_context: SceneWriteContext,
|
||||||
|
) -> Result<usize, String> {
|
||||||
let count = entities.len();
|
let count = entities.len();
|
||||||
let text = serialize_entities(world, entities)?;
|
let text = serialize_entities(world, entities)?;
|
||||||
atomic_write(path, text.as_bytes())?;
|
publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1142,13 +1191,108 @@ fn save_standalone_entities(
|
|||||||
world: &mut World,
|
world: &mut World,
|
||||||
path: &Path,
|
path: &Path,
|
||||||
entities: Vec<Entity>,
|
entities: Vec<Entity>,
|
||||||
|
write_context: SceneWriteContext,
|
||||||
) -> Result<usize, String> {
|
) -> Result<usize, String> {
|
||||||
let count = entities.len();
|
let count = entities.len();
|
||||||
let text = serialize_standalone_entities(world, entities)?;
|
let text = serialize_standalone_entities(world, entities)?;
|
||||||
atomic_write(path, text.as_bytes())?;
|
publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum SceneWriteContext {
|
||||||
|
Active,
|
||||||
|
ActiveSaveAs {
|
||||||
|
expected: FileSnapshot,
|
||||||
|
},
|
||||||
|
Standalone {
|
||||||
|
expected: FileSnapshot,
|
||||||
|
description: &'static str,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_scene_text(
|
||||||
|
world: &mut World,
|
||||||
|
path: &Path,
|
||||||
|
bytes: &[u8],
|
||||||
|
entity_count: usize,
|
||||||
|
context: SceneWriteContext,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match context {
|
||||||
|
SceneWriteContext::Active | SceneWriteContext::ActiveSaveAs { .. } => {
|
||||||
|
let (tab_id, expected) = match context {
|
||||||
|
SceneWriteContext::Active => active_scene_write_baseline(world, path)?,
|
||||||
|
SceneWriteContext::ActiveSaveAs { expected } => {
|
||||||
|
(active_scene_tab_id(world)?, expected)
|
||||||
|
}
|
||||||
|
SceneWriteContext::Standalone { .. } => unreachable!(),
|
||||||
|
};
|
||||||
|
let snapshot = publish_authored_file(
|
||||||
|
world,
|
||||||
|
path,
|
||||||
|
bytes,
|
||||||
|
&expected,
|
||||||
|
FileWriteIntent::Scene {
|
||||||
|
tab_id,
|
||||||
|
entity_count,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let mut io = world.resource_mut::<SceneIo>();
|
||||||
|
let Some(tab) = io.tabs.iter_mut().find(|tab| tab.id == tab_id) else {
|
||||||
|
return Err("saved scene tab no longer exists".into());
|
||||||
|
};
|
||||||
|
tab.disk_snapshot = Some(snapshot);
|
||||||
|
}
|
||||||
|
SceneWriteContext::Standalone {
|
||||||
|
expected,
|
||||||
|
description,
|
||||||
|
} => {
|
||||||
|
publish_authored_file(
|
||||||
|
world,
|
||||||
|
path,
|
||||||
|
bytes,
|
||||||
|
&expected,
|
||||||
|
FileWriteIntent::Standalone {
|
||||||
|
description: description.into(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_scene_write_baseline(
|
||||||
|
world: &World,
|
||||||
|
destination: &Path,
|
||||||
|
) -> Result<(u64, FileSnapshot), String> {
|
||||||
|
let io = world.resource::<SceneIo>();
|
||||||
|
let tab = io
|
||||||
|
.tabs
|
||||||
|
.get(io.active_tab)
|
||||||
|
.ok_or_else(|| "active scene tab is missing".to_string())?;
|
||||||
|
if io.active_path.as_deref() != Some(destination) || tab.path.as_deref() != Some(destination) {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is not the active scene destination; use Save As",
|
||||||
|
destination.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let expected = tab.disk_snapshot.clone().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"scene disk baseline is unavailable for {}; reload it or use Save As",
|
||||||
|
destination.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok((tab.id, expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_scene_tab_id(world: &World) -> Result<u64, String> {
|
||||||
|
let io = world.resource::<SceneIo>();
|
||||||
|
io.tabs
|
||||||
|
.get(io.active_tab)
|
||||||
|
.map(|tab| tab.id)
|
||||||
|
.ok_or_else(|| "active scene tab is missing".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn serialize_entities(world: &mut World, entities: Vec<Entity>) -> Result<String, String> {
|
fn serialize_entities(world: &mut World, entities: Vec<Entity>) -> Result<String, String> {
|
||||||
serialize_entities_inner_with_resources(world, entities, true)
|
serialize_entities_inner_with_resources(world, entities, true)
|
||||||
}
|
}
|
||||||
@ -1430,7 +1574,7 @@ fn restore_recovery(world: &mut World) -> String {
|
|||||||
return "No scene recovery snapshot is available".to_string();
|
return "No scene recovery snapshot is available".to_string();
|
||||||
};
|
};
|
||||||
match load_level(world, &snapshot) {
|
match load_level(world, &snapshot) {
|
||||||
Ok(()) => {
|
Ok(_) => {
|
||||||
mark_recovery_restored(&mut world.resource_mut::<SceneIo>(), active_path);
|
mark_recovery_restored(&mut world.resource_mut::<SceneIo>(), active_path);
|
||||||
world.resource_mut::<EditorHistory>().clear();
|
world.resource_mut::<EditorHistory>().clear();
|
||||||
format!(
|
format!(
|
||||||
@ -1471,8 +1615,12 @@ fn save_recovery_copy_with_dialog(world: &mut World) -> String {
|
|||||||
else {
|
else {
|
||||||
return "Save recovery copy cancelled".to_string();
|
return "Save recovery copy cancelled".to_string();
|
||||||
};
|
};
|
||||||
|
let expected = match FileSnapshot::capture(&destination) {
|
||||||
|
Ok(expected) => expected,
|
||||||
|
Err(error) => return format!("Save recovery copy failed: {error}"),
|
||||||
|
};
|
||||||
|
|
||||||
match write_recovery_copy(&snapshot, &destination) {
|
match write_recovery_copy(world, &snapshot, &destination, &expected) {
|
||||||
Ok(()) => format!(
|
Ok(()) => format!(
|
||||||
"Saved recovery copy {} from {}",
|
"Saved recovery copy {} from {}",
|
||||||
destination.display(),
|
destination.display(),
|
||||||
@ -1482,10 +1630,24 @@ fn save_recovery_copy_with_dialog(world: &mut World) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_recovery_copy(snapshot: &Path, destination: &Path) -> Result<(), String> {
|
fn write_recovery_copy(
|
||||||
|
world: &mut World,
|
||||||
|
snapshot: &Path,
|
||||||
|
destination: &Path,
|
||||||
|
expected: &FileSnapshot,
|
||||||
|
) -> Result<(), String> {
|
||||||
let bytes = std::fs::read(snapshot)
|
let bytes = std::fs::read(snapshot)
|
||||||
.map_err(|error| format!("could not read {}: {error}", snapshot.display()))?;
|
.map_err(|error| format!("could not read {}: {error}", snapshot.display()))?;
|
||||||
atomic_write(destination, &bytes)
|
publish_authored_file(
|
||||||
|
world,
|
||||||
|
destination,
|
||||||
|
&bytes,
|
||||||
|
expected,
|
||||||
|
FileWriteIntent::Standalone {
|
||||||
|
description: "recovery scene copy".into(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_recovery_restored(io: &mut SceneIo, active_path: Option<PathBuf>) {
|
fn mark_recovery_restored(io: &mut SceneIo, active_path: Option<PathBuf>) {
|
||||||
@ -1522,14 +1684,79 @@ fn discard_recovery(world: &mut World) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_level(world: &mut World, path: &Path) -> Result<(), String> {
|
fn load_level(world: &mut World, path: &Path) -> Result<FileSnapshot, String> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Err(format!("{} does not exist yet", path.display()));
|
return Err(format!("{} does not exist yet", path.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let text = std::fs::read_to_string(path)
|
let text = std::fs::read_to_string(path)
|
||||||
.map_err(|err| format!("could not read {}: {err}", path.display()))?;
|
.map_err(|err| format!("could not read {}: {err}", path.display()))?;
|
||||||
load_level_text(world, Some(path), &text)
|
let disk_snapshot = FileSnapshot::from_loaded_bytes(path, text.as_bytes());
|
||||||
|
load_level_text(world, Some(path), &text)?;
|
||||||
|
Ok(disk_snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reload_scene_after_file_conflict(
|
||||||
|
world: &mut World,
|
||||||
|
tab_id: u64,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let active_matches = {
|
||||||
|
let io = world.resource::<SceneIo>();
|
||||||
|
io.tabs
|
||||||
|
.get(io.active_tab)
|
||||||
|
.is_some_and(|tab| tab.id == tab_id)
|
||||||
|
};
|
||||||
|
if !active_matches {
|
||||||
|
return Err("the scene conflict no longer belongs to the active tab".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let disk_snapshot = load_level(world, path)?;
|
||||||
|
{
|
||||||
|
let mut io = world.resource_mut::<SceneIo>();
|
||||||
|
io.active_path = Some(path.to_path_buf());
|
||||||
|
io.recovery_snapshot = None;
|
||||||
|
let active_tab = io.active_tab;
|
||||||
|
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
|
||||||
|
io.mark_clean();
|
||||||
|
}
|
||||||
|
world.resource_mut::<EditorHistory>().clear();
|
||||||
|
remember_path(world, path.to_path_buf());
|
||||||
|
refresh_recovery_notice(world, path);
|
||||||
|
capture_active_tab(world)?;
|
||||||
|
Ok(format!("Reloaded {} from disk", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn adopt_scene_conflict_save_as(
|
||||||
|
world: &mut World,
|
||||||
|
tab_id: u64,
|
||||||
|
path: PathBuf,
|
||||||
|
disk_snapshot: FileSnapshot,
|
||||||
|
entity_count: usize,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let active_matches = {
|
||||||
|
let io = world.resource::<SceneIo>();
|
||||||
|
io.tabs
|
||||||
|
.get(io.active_tab)
|
||||||
|
.is_some_and(|tab| tab.id == tab_id)
|
||||||
|
};
|
||||||
|
if !active_matches {
|
||||||
|
return Err("the scene conflict no longer belongs to the active tab".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut io = world.resource_mut::<SceneIo>();
|
||||||
|
io.active_path = Some(path.clone());
|
||||||
|
let active_tab = io.active_tab;
|
||||||
|
io.tabs[active_tab].disk_snapshot = Some(disk_snapshot);
|
||||||
|
io.mark_clean();
|
||||||
|
}
|
||||||
|
remember_path(world, path.clone());
|
||||||
|
retire_scene_recovery(world, &path);
|
||||||
|
Ok(format!(
|
||||||
|
"Saved {entity_count} level entities to {}",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_level_text(world: &mut World, path: Option<&Path>, text: &str) -> Result<(), String> {
|
fn load_level_text(world: &mut World, path: Option<&Path>, text: &str) -> Result<(), String> {
|
||||||
@ -1979,6 +2206,7 @@ mod tests {
|
|||||||
dirty: true,
|
dirty: true,
|
||||||
snapshot: "dirty inactive tab".to_string(),
|
snapshot: "dirty inactive tab".to_string(),
|
||||||
recovery_snapshot: None,
|
recovery_snapshot: None,
|
||||||
|
disk_snapshot: None,
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
io.has_unsaved_tabs(),
|
io.has_unsaved_tabs(),
|
||||||
@ -1993,6 +2221,61 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_scene_write_refuses_an_external_revision() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"blacksite-scene-external-write-{}",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let path = root.join("assets/levels/main.scn.ron");
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(&path, b"loaded").unwrap();
|
||||||
|
let disk_snapshot = FileSnapshot::capture(&path).unwrap();
|
||||||
|
let mut io = SceneIo {
|
||||||
|
active_path: Some(path.clone()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
io.tabs[0].path = Some(path.clone());
|
||||||
|
io.tabs[0].disk_snapshot = Some(disk_snapshot);
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(io);
|
||||||
|
std::fs::write(&path, b"external").unwrap();
|
||||||
|
|
||||||
|
let result = publish_scene_text(&mut world, &path, b"editor", 1, SceneWriteContext::Active);
|
||||||
|
|
||||||
|
assert!(result.unwrap_err().contains("changed outside Blacksite"));
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"external");
|
||||||
|
assert!(world.resource::<SceneIo>().dirty);
|
||||||
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scene_save_as_refuses_a_file_created_after_destination_selection() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"blacksite-scene-save-as-race-{}",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let path = root.join("assets/levels/new.scn.ron");
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
let expected = FileSnapshot::missing();
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
std::fs::write(&path, b"external").unwrap();
|
||||||
|
|
||||||
|
let result = publish_scene_text(
|
||||||
|
&mut world,
|
||||||
|
&path,
|
||||||
|
b"editor",
|
||||||
|
1,
|
||||||
|
SceneWriteContext::ActiveSaveAs { expected },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(result.unwrap_err().contains("changed outside Blacksite"));
|
||||||
|
assert_eq!(std::fs::read(&path).unwrap(), b"external");
|
||||||
|
assert!(world.resource::<SceneIo>().dirty);
|
||||||
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn composition_edits_participate_in_undo_and_redo() {
|
fn composition_edits_participate_in_undo_and_redo() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@ -2770,8 +3053,13 @@ mod tests {
|
|||||||
.insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());
|
.insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());
|
||||||
app.world_mut().init_resource::<SceneIo>();
|
app.world_mut().init_resource::<SceneIo>();
|
||||||
|
|
||||||
load_level(app.world_mut(), &prefab_path).unwrap();
|
let expected = load_level(app.world_mut(), &prefab_path).unwrap();
|
||||||
save_level(app.world_mut(), &prefab_path).unwrap();
|
save_level(
|
||||||
|
app.world_mut(),
|
||||||
|
&prefab_path,
|
||||||
|
SceneWriteContext::ActiveSaveAs { expected },
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
scene::validate_prefab_graph(&prefab_path, &root).unwrap();
|
scene::validate_prefab_graph(&prefab_path, &root).unwrap();
|
||||||
let document =
|
let document =
|
||||||
@ -2813,8 +3101,11 @@ mod tests {
|
|||||||
let destination = root.join("kept.scn.ron");
|
let destination = root.join("kept.scn.ron");
|
||||||
std::fs::create_dir_all(&root).unwrap();
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
std::fs::write(&snapshot, b"recovered scene bytes").unwrap();
|
std::fs::write(&snapshot, b"recovered scene bytes").unwrap();
|
||||||
|
let expected = FileSnapshot::missing();
|
||||||
|
let mut world = World::new();
|
||||||
|
world.init_resource::<SceneIo>();
|
||||||
|
|
||||||
write_recovery_copy(&snapshot, &destination).unwrap();
|
write_recovery_copy(&mut world, &snapshot, &destination, &expected).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read(&destination).unwrap(),
|
std::fs::read(&destination).unwrap(),
|
||||||
|
|||||||
@ -20,6 +20,10 @@ use crate::assets::{
|
|||||||
AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets, ASSETS_ROOT,
|
AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets, ASSETS_ROOT,
|
||||||
BUILTINS_FOLDER,
|
BUILTINS_FOLDER,
|
||||||
};
|
};
|
||||||
|
use crate::project::collaboration::{
|
||||||
|
file_status_indicator_ui, publish_authored_file, CollaborationState, FileSnapshot,
|
||||||
|
FileWriteIntent,
|
||||||
|
};
|
||||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||||
|
|
||||||
use super::state::{
|
use super::state::{
|
||||||
@ -1857,7 +1861,7 @@ fn top_level_asset_details_panel(
|
|||||||
selected_entities: &SelectedEntities,
|
selected_entities: &SelectedEntities,
|
||||||
asset: &EditorAsset,
|
asset: &EditorAsset,
|
||||||
) {
|
) {
|
||||||
asset_details_header(ui, asset);
|
asset_details_header(world, ui, asset);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in"));
|
detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in"));
|
||||||
detail_row(ui, "Folder", asset.folder_path.as_str());
|
detail_row(ui, "Folder", asset.folder_path.as_str());
|
||||||
@ -1946,7 +1950,7 @@ fn top_level_asset_details_panel(
|
|||||||
asset_action_buttons(world, ui, selected_entities, asset);
|
asset_action_buttons(world, ui, selected_entities, asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn asset_details_header(ui: &mut egui::Ui, asset: &EditorAsset) {
|
fn asset_details_header(world: &World, ui: &mut egui::Ui, asset: &EditorAsset) {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(kind_icon(&asset.kind).as_str())
|
egui::RichText::new(kind_icon(&asset.kind).as_str())
|
||||||
@ -1959,6 +1963,13 @@ fn asset_details_header(ui: &mut egui::Ui, asset: &EditorAsset) {
|
|||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.strong(asset.label.as_str());
|
ui.strong(asset.label.as_str());
|
||||||
ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM));
|
ui.small(egui::RichText::new(kind_label(&asset.kind)).color(TEXT_DIM));
|
||||||
|
if let (Some(state), Some(path)) = (
|
||||||
|
world.get_resource::<CollaborationState>(),
|
||||||
|
asset.path.as_deref(),
|
||||||
|
) {
|
||||||
|
let status = state.file_status(Path::new(path));
|
||||||
|
let _ = file_status_indicator_ui(ui, &status, Path::new(path));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -2434,10 +2445,19 @@ fn ensure_material_draft(world: &mut World, path: &str, fallback_label: &str) {
|
|||||||
if !needs_load {
|
if !needs_load {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let draft = match MaterialAsset::load_from_path(path) {
|
let loaded = fs::read_to_string(path)
|
||||||
Ok(asset) => MaterialAssetDraft {
|
.map_err(|error| format!("could not read {path}: {error}"))
|
||||||
|
.and_then(|text| {
|
||||||
|
let snapshot = FileSnapshot::from_loaded_bytes(Path::new(path), text.as_bytes());
|
||||||
|
ron::from_str::<MaterialAsset>(&text)
|
||||||
|
.map(|asset| (asset, snapshot))
|
||||||
|
.map_err(|error| format!("invalid material RON in {path}: {error}"))
|
||||||
|
});
|
||||||
|
let draft = match loaded {
|
||||||
|
Ok((asset, disk_snapshot)) => MaterialAssetDraft {
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
asset,
|
asset,
|
||||||
|
disk_snapshot,
|
||||||
error: None,
|
error: None,
|
||||||
},
|
},
|
||||||
Err(error) => MaterialAssetDraft {
|
Err(error) => MaterialAssetDraft {
|
||||||
@ -2450,6 +2470,8 @@ fn ensure_material_draft(world: &mut World, path: &str, fallback_label: &str) {
|
|||||||
render_state: shared::MaterialRenderState::default(),
|
render_state: shared::MaterialRenderState::default(),
|
||||||
material: MaterialDesc::default(),
|
material: MaterialDesc::default(),
|
||||||
},
|
},
|
||||||
|
disk_snapshot: FileSnapshot::capture(Path::new(path))
|
||||||
|
.unwrap_or_else(|_| FileSnapshot::missing()),
|
||||||
error: Some(error),
|
error: Some(error),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -2467,17 +2489,23 @@ fn save_material_draft(world: &mut World) {
|
|||||||
let result = ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default())
|
let result = ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default())
|
||||||
.map_err(|error| format!("could not serialize material {}: {error}", draft.path))
|
.map_err(|error| format!("could not serialize material {}: {error}", draft.path))
|
||||||
.and_then(|text| {
|
.and_then(|text| {
|
||||||
fs::write(&draft.path, text)
|
publish_authored_file(
|
||||||
.map_err(|error| format!("could not write material {}: {error}", draft.path))
|
world,
|
||||||
|
Path::new(&draft.path),
|
||||||
|
text.as_bytes(),
|
||||||
|
&draft.disk_snapshot,
|
||||||
|
FileWriteIntent::Material,
|
||||||
|
)
|
||||||
});
|
});
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(disk_snapshot) => {
|
||||||
if let Some(current) = world
|
if let Some(current) = world
|
||||||
.resource_mut::<AssetBrowserUiState>()
|
.resource_mut::<AssetBrowserUiState>()
|
||||||
.material_draft
|
.material_draft
|
||||||
.as_mut()
|
.as_mut()
|
||||||
{
|
{
|
||||||
current.error = None;
|
current.error = None;
|
||||||
|
current.disk_snapshot = disk_snapshot;
|
||||||
}
|
}
|
||||||
world.resource_mut::<EditorAssets>().refresh();
|
world.resource_mut::<EditorAssets>().refresh();
|
||||||
invalidate_on_catalog_refresh(world);
|
invalidate_on_catalog_refresh(world);
|
||||||
@ -2556,8 +2584,13 @@ fn create_material_instance_from_base(world: &mut World, base_path: &str, base_l
|
|||||||
let result = ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default())
|
let result = ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default())
|
||||||
.map_err(|error| format!("could not serialize material instance: {error}"))
|
.map_err(|error| format!("could not serialize material instance: {error}"))
|
||||||
.and_then(|text| {
|
.and_then(|text| {
|
||||||
fs::write(&path, text)
|
publish_authored_file(
|
||||||
.map_err(|error| format!("could not write {}: {error}", path.display()))
|
world,
|
||||||
|
&path,
|
||||||
|
text.as_bytes(),
|
||||||
|
&FileSnapshot::missing(),
|
||||||
|
FileWriteIntent::MaterialInstance,
|
||||||
|
)
|
||||||
});
|
});
|
||||||
if let Err(error) = result {
|
if let Err(error) = result {
|
||||||
world.resource_mut::<SceneIo>().status = error;
|
world.resource_mut::<SceneIo>().status = error;
|
||||||
@ -2845,10 +2878,19 @@ fn ensure_material_instance_draft(world: &mut World, path: &str, fallback_label:
|
|||||||
if !needs_load {
|
if !needs_load {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let draft = match MaterialInstanceAsset::load_from_path(path) {
|
let loaded = fs::read_to_string(path)
|
||||||
Ok(asset) => MaterialInstanceAssetDraft {
|
.map_err(|error| format!("could not read {path}: {error}"))
|
||||||
|
.and_then(|text| {
|
||||||
|
let snapshot = FileSnapshot::from_loaded_bytes(Path::new(path), text.as_bytes());
|
||||||
|
ron::from_str::<MaterialInstanceAsset>(&text)
|
||||||
|
.map(|asset| (asset, snapshot))
|
||||||
|
.map_err(|error| format!("invalid material-instance RON in {path}: {error}"))
|
||||||
|
});
|
||||||
|
let draft = match loaded {
|
||||||
|
Ok((asset, disk_snapshot)) => MaterialInstanceAssetDraft {
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
asset,
|
asset,
|
||||||
|
disk_snapshot,
|
||||||
error: None,
|
error: None,
|
||||||
},
|
},
|
||||||
Err(error) => MaterialInstanceAssetDraft {
|
Err(error) => MaterialInstanceAssetDraft {
|
||||||
@ -2860,6 +2902,8 @@ fn ensure_material_instance_draft(world: &mut World, path: &str, fallback_label:
|
|||||||
parameters: Vec::new(),
|
parameters: Vec::new(),
|
||||||
textures: Vec::new(),
|
textures: Vec::new(),
|
||||||
},
|
},
|
||||||
|
disk_snapshot: FileSnapshot::capture(Path::new(path))
|
||||||
|
.unwrap_or_else(|_| FileSnapshot::missing()),
|
||||||
error: Some(error),
|
error: Some(error),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -2884,18 +2928,24 @@ fn save_material_instance_draft(world: &mut World) {
|
|||||||
ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default())
|
ron::ser::to_string_pretty(&draft.asset, ron::ser::PrettyConfig::default())
|
||||||
.map_err(|error| format!("could not serialize material instance: {error}"))
|
.map_err(|error| format!("could not serialize material instance: {error}"))
|
||||||
.and_then(|text| {
|
.and_then(|text| {
|
||||||
fs::write(&draft.path, text)
|
publish_authored_file(
|
||||||
.map_err(|error| format!("could not write {}: {error}", draft.path))
|
world,
|
||||||
|
Path::new(&draft.path),
|
||||||
|
text.as_bytes(),
|
||||||
|
&draft.disk_snapshot,
|
||||||
|
FileWriteIntent::MaterialInstance,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(disk_snapshot) => {
|
||||||
if let Some(current) = world
|
if let Some(current) = world
|
||||||
.resource_mut::<AssetBrowserUiState>()
|
.resource_mut::<AssetBrowserUiState>()
|
||||||
.material_instance_draft
|
.material_instance_draft
|
||||||
.as_mut()
|
.as_mut()
|
||||||
{
|
{
|
||||||
current.error = None;
|
current.error = None;
|
||||||
|
current.disk_snapshot = disk_snapshot;
|
||||||
}
|
}
|
||||||
world.resource_mut::<EditorAssets>().refresh();
|
world.resource_mut::<EditorAssets>().refresh();
|
||||||
invalidate_on_catalog_refresh(world);
|
invalidate_on_catalog_refresh(world);
|
||||||
@ -4380,10 +4430,181 @@ fn dedup_strings(values: &mut Vec<String>) {
|
|||||||
values.retain(|value| seen.insert(value.clone()));
|
values.retain(|value| seen.insert(value.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_material_conflict_destination(
|
||||||
|
world: &World,
|
||||||
|
destination: &Path,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let root = PathBuf::from(&world.resource::<crate::project_io::ProjectWorkspace>().root);
|
||||||
|
let root = root.canonicalize().unwrap_or(root);
|
||||||
|
let absolute = if destination.is_absolute() {
|
||||||
|
destination.to_path_buf()
|
||||||
|
} else {
|
||||||
|
root.join(destination)
|
||||||
|
};
|
||||||
|
let absolute = absolute
|
||||||
|
.parent()
|
||||||
|
.and_then(|parent| parent.canonicalize().ok())
|
||||||
|
.and_then(|parent| destination.file_name().map(|name| parent.join(name)))
|
||||||
|
.unwrap_or(absolute);
|
||||||
|
let relative = absolute.strip_prefix(&root).map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"Material assets must stay inside {}",
|
||||||
|
root.join("assets").display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if !relative.starts_with("assets") {
|
||||||
|
return Err(format!(
|
||||||
|
"Material assets must stay inside {}",
|
||||||
|
root.join("assets").display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(relative.to_string_lossy().replace('\\', "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reload_material_after_file_conflict(
|
||||||
|
world: &mut World,
|
||||||
|
path: &Path,
|
||||||
|
intent: &FileWriteIntent,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let path = path.to_string_lossy();
|
||||||
|
{
|
||||||
|
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
||||||
|
match intent {
|
||||||
|
FileWriteIntent::Material => {
|
||||||
|
if state
|
||||||
|
.material_draft
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|draft| draft.path == path)
|
||||||
|
{
|
||||||
|
state.material_draft = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FileWriteIntent::MaterialInstance => {
|
||||||
|
if state
|
||||||
|
.material_instance_draft
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|draft| draft.path == path)
|
||||||
|
{
|
||||||
|
state.material_instance_draft = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err("the conflict is not an editable material document".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
world.resource_mut::<EditorAssets>().refresh();
|
||||||
|
invalidate_on_catalog_refresh(world);
|
||||||
|
Ok(format!("Reloaded {} from disk", path))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn adopt_material_conflict_save_as(
|
||||||
|
world: &mut World,
|
||||||
|
original_path: &Path,
|
||||||
|
catalog_path: String,
|
||||||
|
disk_snapshot: FileSnapshot,
|
||||||
|
intent: &FileWriteIntent,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let original_path = original_path.to_string_lossy();
|
||||||
|
{
|
||||||
|
let mut state = world.resource_mut::<AssetBrowserUiState>();
|
||||||
|
match intent {
|
||||||
|
FileWriteIntent::Material => {
|
||||||
|
if let Some(draft) = state
|
||||||
|
.material_draft
|
||||||
|
.as_mut()
|
||||||
|
.filter(|draft| draft.path == original_path)
|
||||||
|
{
|
||||||
|
draft.path.clone_from(&catalog_path);
|
||||||
|
draft.disk_snapshot = disk_snapshot;
|
||||||
|
draft.error = None;
|
||||||
|
} else {
|
||||||
|
let asset = MaterialAsset::load_from_path(&catalog_path)?;
|
||||||
|
state.material_draft = Some(MaterialAssetDraft {
|
||||||
|
path: catalog_path.clone(),
|
||||||
|
asset,
|
||||||
|
disk_snapshot,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FileWriteIntent::MaterialInstance => {
|
||||||
|
if let Some(draft) = state
|
||||||
|
.material_instance_draft
|
||||||
|
.as_mut()
|
||||||
|
.filter(|draft| draft.path == original_path)
|
||||||
|
{
|
||||||
|
draft.path.clone_from(&catalog_path);
|
||||||
|
draft.disk_snapshot = disk_snapshot;
|
||||||
|
draft.error = None;
|
||||||
|
} else {
|
||||||
|
let asset = MaterialInstanceAsset::load_from_path(&catalog_path)?;
|
||||||
|
state.material_instance_draft = Some(MaterialInstanceAssetDraft {
|
||||||
|
path: catalog_path.clone(),
|
||||||
|
asset,
|
||||||
|
disk_snapshot,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err("the conflict is not an editable material document".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut assets = world.resource_mut::<EditorAssets>();
|
||||||
|
assets.refresh();
|
||||||
|
assets.select(AssetSelection::File(catalog_path.clone()));
|
||||||
|
}
|
||||||
|
invalidate_on_catalog_refresh(world);
|
||||||
|
Ok(format!("Saved material copy to {catalog_path}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn material_apply_refuses_an_external_revision() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"blacksite-material-external-write-{}",
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let path = root.join("assets/materials/test.ron");
|
||||||
|
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
fs::write(&path, b"loaded").unwrap();
|
||||||
|
let disk_snapshot = FileSnapshot::capture(&path).unwrap();
|
||||||
|
let draft = MaterialAssetDraft {
|
||||||
|
path: path.to_string_lossy().into_owned(),
|
||||||
|
asset: MaterialAsset {
|
||||||
|
schema_version: shared::MATERIAL_ASSET_SCHEMA_VERSION,
|
||||||
|
label: "Test".into(),
|
||||||
|
shader: None,
|
||||||
|
shader_ref: None,
|
||||||
|
render_state: shared::MaterialRenderState::default(),
|
||||||
|
material: MaterialDesc::default(),
|
||||||
|
},
|
||||||
|
disk_snapshot,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
let state = AssetBrowserUiState {
|
||||||
|
material_draft: Some(draft),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut world = World::new();
|
||||||
|
world.insert_resource(state);
|
||||||
|
world.insert_resource(SceneIo::default());
|
||||||
|
fs::write(&path, b"external").unwrap();
|
||||||
|
|
||||||
|
save_material_draft(&mut world);
|
||||||
|
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), b"external");
|
||||||
|
assert!(world
|
||||||
|
.resource::<AssetBrowserUiState>()
|
||||||
|
.material_draft
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|draft| draft.error.as_deref())
|
||||||
|
.is_some_and(|error| error.contains("changed outside Blacksite")));
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_filter_matches_only_audio_clips() {
|
fn audio_filter_matches_only_audio_clips() {
|
||||||
let audio = EditorAsset {
|
let audio = EditorAsset {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ use shared::{MaterialAsset, MaterialInstanceAsset};
|
|||||||
|
|
||||||
use crate::asset_db::ImportSettings;
|
use crate::asset_db::ImportSettings;
|
||||||
use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER};
|
use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER};
|
||||||
|
use crate::project::collaboration::FileSnapshot;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(crate) enum AssetBrowserView {
|
pub(crate) enum AssetBrowserView {
|
||||||
@ -69,6 +70,7 @@ pub(crate) struct ImportSettingsDraft {
|
|||||||
pub(crate) struct MaterialAssetDraft {
|
pub(crate) struct MaterialAssetDraft {
|
||||||
pub(crate) path: String,
|
pub(crate) path: String,
|
||||||
pub(crate) asset: MaterialAsset,
|
pub(crate) asset: MaterialAsset,
|
||||||
|
pub(crate) disk_snapshot: FileSnapshot,
|
||||||
pub(crate) error: Option<String>,
|
pub(crate) error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +78,7 @@ pub(crate) struct MaterialAssetDraft {
|
|||||||
pub(crate) struct MaterialInstanceAssetDraft {
|
pub(crate) struct MaterialInstanceAssetDraft {
|
||||||
pub(crate) path: String,
|
pub(crate) path: String,
|
||||||
pub(crate) asset: MaterialInstanceAsset,
|
pub(crate) asset: MaterialInstanceAsset,
|
||||||
|
pub(crate) disk_snapshot: FileSnapshot,
|
||||||
pub(crate) error: Option<String>,
|
pub(crate) error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,10 @@ use crate::project_io::UserPreferences;
|
|||||||
use crate::selection::SelectedEntity;
|
use crate::selection::SelectedEntity;
|
||||||
use crate::state::EditorMode;
|
use crate::state::EditorMode;
|
||||||
|
|
||||||
|
pub(crate) use asset_browser::{
|
||||||
|
adopt_material_conflict_save_as, reload_material_after_file_conflict,
|
||||||
|
validate_material_conflict_destination,
|
||||||
|
};
|
||||||
pub use build::BuildPanel;
|
pub use build::BuildPanel;
|
||||||
pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
pub use diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
||||||
pub use layout::LayoutSaveTimer;
|
pub use layout::LayoutSaveTimer;
|
||||||
@ -215,6 +219,8 @@ impl UiState {
|
|||||||
project_settings_window(world, ctx, &mut panel);
|
project_settings_window(world, ctx, &mut panel);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
crate::collaboration::file_conflict_modal(world, ctx);
|
||||||
|
|
||||||
if ctx.input(|input| input.key_pressed(egui::Key::F1)) {
|
if ctx.input(|input| input.key_pressed(egui::Key::F1)) {
|
||||||
world.resource_mut::<ViewportUiState>().shortcuts_open = true;
|
world.resource_mut::<ViewportUiState>().shortcuts_open = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ use egui_phosphor_icons::icons;
|
|||||||
|
|
||||||
use crate::history::EditorHistory;
|
use crate::history::EditorHistory;
|
||||||
use crate::operators::ActiveOperator;
|
use crate::operators::ActiveOperator;
|
||||||
|
use crate::project::collaboration::{file_status_indicator_ui, CollaborationState};
|
||||||
use crate::scene_io::SceneIo;
|
use crate::scene_io::SceneIo;
|
||||||
use crate::state::{EditorMode, PlayPaused};
|
use crate::state::{EditorMode, PlayPaused};
|
||||||
|
|
||||||
@ -47,6 +48,16 @@ pub fn status_bar_ui(
|
|||||||
.on_hover_text(status);
|
.on_hover_text(status);
|
||||||
},
|
},
|
||||||
|ui| {
|
|ui| {
|
||||||
|
if let (Some(state), Some(path)) = (
|
||||||
|
world.get_resource::<CollaborationState>(),
|
||||||
|
world.resource::<SceneIo>().active_path.as_deref(),
|
||||||
|
) {
|
||||||
|
let status = state.file_status(path);
|
||||||
|
if status.indicator().is_some() {
|
||||||
|
let _ = file_status_indicator_ui(ui, &status, path);
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
}
|
||||||
let (mode_icon, mode_label, mode_color) = match mode {
|
let (mode_icon, mode_label, mode_color) = match mode {
|
||||||
EditorMode::Editing => (icons::PENCIL_SIMPLE, "EDIT", ACCENT),
|
EditorMode::Editing => (icons::PENCIL_SIMPLE, "EDIT", ACCENT),
|
||||||
EditorMode::Playing => {
|
EditorMode::Playing => {
|
||||||
|
|||||||
@ -227,17 +227,22 @@ impl Default for InputSettings {
|
|||||||
|
|
||||||
/// Loads settings from disk, falling back to defaults.
|
/// Loads settings from disk, falling back to defaults.
|
||||||
pub fn load_project_settings_from_path(path: &str) -> ProjectSettings {
|
pub fn load_project_settings_from_path(path: &str) -> ProjectSettings {
|
||||||
|
load_project_settings_with_source(path).0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads settings plus the exact source text used when the file was readable.
|
||||||
|
pub fn load_project_settings_with_source(path: &str) -> (ProjectSettings, Option<String>) {
|
||||||
match std::fs::read_to_string(path) {
|
match std::fs::read_to_string(path) {
|
||||||
Ok(contents) => match ron::from_str(&contents) {
|
Ok(contents) => match ron::from_str(&contents) {
|
||||||
Ok(settings) => settings,
|
Ok(settings) => (settings, Some(contents)),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
warn!("Failed to parse {path}: {error}; using defaults");
|
warn!("Failed to parse {path}: {error}; using defaults");
|
||||||
ProjectSettings::default()
|
(ProjectSettings::default(), Some(contents))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
warn!("Could not read {path}: {error}; using defaults");
|
warn!("Could not read {path}: {error}; using defaults");
|
||||||
ProjectSettings::default()
|
(ProjectSettings::default(), None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -266,6 +271,27 @@ mod tests {
|
|||||||
assert_eq!(parsed.rendering.gi_mode, settings.rendering.gi_mode);
|
assert_eq!(parsed.rendering.gi_mode, settings.rendering.gi_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_load_returns_the_exact_source_for_revision_guards() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"blacksite-settings-source-{}",
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
let path = root.join("project.ron");
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
let source = save_project_settings_to_string(&ProjectSettings::default()).unwrap();
|
||||||
|
std::fs::write(&path, &source).unwrap();
|
||||||
|
|
||||||
|
let (settings, loaded_source) = load_project_settings_with_source(path.to_str().unwrap());
|
||||||
|
|
||||||
|
assert_eq!(settings.name, ProjectSettings::default().name);
|
||||||
|
assert_eq!(loaded_source.as_deref(), Some(source.as_str()));
|
||||||
|
std::fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_manifest_defaults_project_identity_fields() {
|
fn legacy_manifest_defaults_project_identity_fields() {
|
||||||
let legacy = r#"(
|
let legacy = r#"(
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
load_project_settings_from_path, save_project_settings_to_string, PhysicsSettings,
|
load_project_settings_with_source, save_project_settings_to_string, PhysicsSettings,
|
||||||
ProjectSettings, DEFAULT_PROJECT_PATH,
|
ProjectSettings, DEFAULT_PROJECT_PATH,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -56,6 +56,8 @@ impl SimTuning {
|
|||||||
pub struct ProjectSettingsIo {
|
pub struct ProjectSettingsIo {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub dirty: bool,
|
pub dirty: bool,
|
||||||
|
/// Exact source loaded or last saved by this process; collaboration guards hash it.
|
||||||
|
pub loaded_source: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProjectSettingsIo {
|
impl Default for ProjectSettingsIo {
|
||||||
@ -63,6 +65,7 @@ impl Default for ProjectSettingsIo {
|
|||||||
Self {
|
Self {
|
||||||
path: DEFAULT_PROJECT_PATH.into(),
|
path: DEFAULT_PROJECT_PATH.into(),
|
||||||
dirty: false,
|
dirty: false,
|
||||||
|
loaded_source: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -91,8 +94,9 @@ impl Plugin for ProjectSettingsPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_settings(mut commands: Commands, io: Res<ProjectSettingsIo>) {
|
fn load_settings(mut commands: Commands, mut io: ResMut<ProjectSettingsIo>) {
|
||||||
let settings = load_project_settings_from_path(&io.path);
|
let (settings, source) = load_project_settings_with_source(&io.path);
|
||||||
|
io.loaded_source = source;
|
||||||
let tuning = SimTuning::from_physics(&settings.physics);
|
let tuning = SimTuning::from_physics(&settings.physics);
|
||||||
commands.insert_resource(settings);
|
commands.insert_resource(settings);
|
||||||
commands.insert_resource(tuning);
|
commands.insert_resource(tuning);
|
||||||
@ -103,13 +107,17 @@ pub fn sync_sim_tuning(settings: &ProjectSettings, mut tuning: ResMut<SimTuning>
|
|||||||
*tuning = SimTuning::from_physics(&settings.physics);
|
*tuning = SimTuning::from_physics(&settings.physics);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persists settings to the path in [`ProjectSettingsIo`].
|
/// Low-level unconditional persistence for non-editor callers.
|
||||||
|
///
|
||||||
|
/// The editor uses its guarded authored-file publication path instead so external revisions cannot
|
||||||
|
/// be overwritten.
|
||||||
pub fn save_project_settings(
|
pub fn save_project_settings(
|
||||||
settings: &ProjectSettings,
|
settings: &ProjectSettings,
|
||||||
io: &mut ProjectSettingsIo,
|
io: &mut ProjectSettingsIo,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let text = save_project_settings_to_string(settings).map_err(|error| error.to_string())?;
|
let text = save_project_settings_to_string(settings).map_err(|error| error.to_string())?;
|
||||||
std::fs::write(&io.path, text).map_err(|error| error.to_string())?;
|
std::fs::write(&io.path, &text).map_err(|error| error.to_string())?;
|
||||||
|
io.loaded_source = Some(text);
|
||||||
io.dirty = false;
|
io.dirty = false;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
|||||||
| [0034](adr/0034-registry-driven-authoring-components.md) | Stable component IDs, reflected lifecycle/history, composition, and independent active state |
|
| [0034](adr/0034-registry-driven-authoring-components.md) | Stable component IDs, reflected lifecycle/history, composition, and independent active state |
|
||||||
| [0035](adr/0035-shared-material-assets-and-renderer-slots.md) | Shared Material/Material Instance assets, stable renderer slots, and runtime-only property blocks |
|
| [0035](adr/0035-shared-material-assets-and-renderer-slots.md) | Shared Material/Material Instance assets, stable renderer slots, and runtime-only property blocks |
|
||||||
| [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary |
|
| [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary |
|
||||||
|
| [0037](adr/0037-collaborative-authored-file-safety.md) | Exact authored-file revisions, observational Git status, and optional ownership providers |
|
||||||
|
|
||||||
## Editor framework
|
## Editor framework
|
||||||
|
|
||||||
@ -75,6 +76,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
|||||||
| [editor/navigation-authoring.md](editor/navigation-authoring.md) | Bounds/obstacles/areas/links, deterministic bake, overlay, path preview, and runtime queries |
|
| [editor/navigation-authoring.md](editor/navigation-authoring.md) | Bounds/obstacles/areas/links, deterministic bake, overlay, path preview, and runtime queries |
|
||||||
| [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract |
|
| [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract |
|
||||||
| [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics |
|
| [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics |
|
||||||
|
| [editor/collaborative-file-safety.md](editor/collaborative-file-safety.md) | Guarded authored writes, compact Git/read-only status, conflict recovery, and ownership providers |
|
||||||
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
|
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
|
||||||
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
|
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
|
||||||
|
|
||||||
@ -95,6 +97,7 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
|
|||||||
| `animation_authoring_*.plan.md` | M7 glTF skeletal animation import, preview, controller, and runtime acceptance |
|
| `animation_authoring_*.plan.md` | M7 glTF skeletal animation import, preview, controller, and runtime acceptance |
|
||||||
| `navigation_authoring_*.plan.md` | M7 navigation bounds, deterministic bake, diagnostics, preview, and runtime query API |
|
| `navigation_authoring_*.plan.md` | M7 navigation bounds, deterministic bake, diagnostics, preview, and runtime query API |
|
||||||
| `renderer_material_component_foundation_*.plan.md` | Renderer/material slots, skinned pose lifecycle, Surface ABI, and component foundation |
|
| `renderer_material_component_foundation_*.plan.md` | Renderer/material slots, skinned pose lifecycle, Surface ABI, and component foundation |
|
||||||
|
| `source_control_collaboration_safety_*.plan.md` | Exact authored-file guards, observational Git status, conflict recovery, and provider contract |
|
||||||
|
|
||||||
## Crate responsibilities (quick reference)
|
## Crate responsibilities (quick reference)
|
||||||
|
|
||||||
|
|||||||
53
docs/adr/0037-collaborative-authored-file-safety.md
Normal file
53
docs/adr/0037-collaborative-authored-file-safety.md
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# ADR 0037: Collaborative Authored-File Safety
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Transactional replacement protects an authored file from partial writes, but it does not prevent
|
||||||
|
an editor session from replacing a newer revision written by another tool or collaborator. Git can
|
||||||
|
explain common workspace states, while Perforce-style ownership or lock services may be present in
|
||||||
|
some teams. Neither Git nor a provider may be assumed, and source-control integration must never
|
||||||
|
stage, reset, discard, commit, or otherwise mutate repository state.
|
||||||
|
|
||||||
|
Blacksite needs one save boundary for user-authored scenes and assets. The boundary must detect
|
||||||
|
changes using file content rather than timestamps alone, preserve atomic replacement, explain
|
||||||
|
read-only and collaboration conflicts, and remain useful in projects with no repository or lock
|
||||||
|
provider.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Every loaded authored document records a `FileSnapshot` containing a BLAKE3 content revision and
|
||||||
|
comparison metadata. Missing files have an explicit revision so create/save-as races are guarded.
|
||||||
|
- Authored writes compare the current revision with the caller's baseline before serialization is
|
||||||
|
published and again immediately before the same-directory atomic rename. A mismatch, read-only
|
||||||
|
target, or provider lock blocks publication and leaves the existing file untouched.
|
||||||
|
- The conflict workflow offers reload, metadata comparison, save-as, and cancel. There is no
|
||||||
|
force-overwrite action. Reload semantics are owned by the document type so scene and asset state
|
||||||
|
cannot be confused.
|
||||||
|
- Git status is collected asynchronously with read-only commands and parsed from NUL-delimited
|
||||||
|
porcelain output. The editor reports clean, modified, untracked, and conflicted state for the
|
||||||
|
active scene and selected asset without making Git a project requirement.
|
||||||
|
- Optional ownership integrations implement a thread-safe provider trait over the currently
|
||||||
|
tracked project-relative paths. Provider absence performs no work and produces no warning.
|
||||||
|
Provider errors are diagnostic status, not a reason to disable filesystem revision protection.
|
||||||
|
- Generated registries, import artifacts, caches, recovery snapshots, and package output do not
|
||||||
|
open authored-file conflict UI. Their existing subsystem-specific transactional or regenerative
|
||||||
|
rules remain authoritative.
|
||||||
|
- Source-control integration is observational. Blacksite never stages, commits, resets, checks out,
|
||||||
|
discards, or changes locks through the Git status path.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- External changes to loaded scenes, prefabs, materials, material instances, and project settings
|
||||||
|
cannot be silently replaced by normal editor save/apply/history paths.
|
||||||
|
- Timestamp-only changes do not create false content conflicts, while metadata remains available
|
||||||
|
for human comparison.
|
||||||
|
- A save conflict requires an explicit recovery choice and may interrupt a bulk save operation.
|
||||||
|
- Git is invoked periodically in a worker thread; the frame loop does not wait for repository I/O.
|
||||||
|
- Provider implementations must be deterministic, bounded, and safe to call off the main thread.
|
||||||
|
- Native filesystem replacement cannot coordinate with unrelated writers that ignore advisory
|
||||||
|
conventions after Blacksite's final revision check; the narrow check-to-rename interval is the
|
||||||
|
platform boundary, and deterministic race hooks cover the editor-controlled interval.
|
||||||
@ -23,6 +23,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
|||||||
| [navigation-authoring.md](navigation-authoring.md) | Bounds, source geometry, deterministic bake, viewport path preview, and runtime query workflow |
|
| [navigation-authoring.md](navigation-authoring.md) | Bounds, source geometry, deterministic bake, viewport path preview, and runtime query workflow |
|
||||||
| [extensibility.md](extensibility.md) | Static authoring component lifecycle registration, stable IDs, composition, and generic history |
|
| [extensibility.md](extensibility.md) | Static authoring component lifecycle registration, stable IDs, composition, and generic history |
|
||||||
| [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration |
|
| [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration |
|
||||||
|
| [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers |
|
||||||
| [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation |
|
| [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation |
|
||||||
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
|
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
|
||||||
|
|
||||||
@ -43,6 +44,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
|||||||
| `assets/` | Catalog, asset DB, static mesh artifacts, `thumbnails/`, `materials.rs`, prefab overrides v2 | this file (below), prefab-authoring.md, ADR 0017, ADR 0027 |
|
| `assets/` | Catalog, asset DB, static mesh artifacts, `thumbnails/`, `materials.rs`, prefab overrides v2 | this file (below), prefab-authoring.md, ADR 0017, ADR 0027 |
|
||||||
| `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 |
|
| `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 |
|
||||||
| `project/` | Workspace, settings UI, user prefs, support diagnostics | roadmap Phase 1 |
|
| `project/` | Workspace, settings UI, user prefs, support diagnostics | roadmap Phase 1 |
|
||||||
|
| `project/collaboration.rs` | Guarded authored writes, asynchronous Git status, and optional ownership providers | collaborative-file-safety.md, ADR 0037 |
|
||||||
| `project/session.rs` | Versioned XDG session document, clean marker, and safe resume | session-recovery.md, ADR 0024 |
|
| `project/session.rs` | Versioned XDG session document, clean marker, and safe resume | session-recovery.md, ADR 0024 |
|
||||||
| `project/diagnostics_bundle.rs` | Privacy-bounded transactional support report export | session-recovery.md, ADR 0024 |
|
| `project/diagnostics_bundle.rs` | Privacy-bounded transactional support report export | session-recovery.md, ADR 0024 |
|
||||||
| `project/launcher.rs` | Strict project inspection, CLI activation, recent filtering, and sandbox scaffolding | project-launcher.md, ADR 0025 |
|
| `project/launcher.rs` | Strict project inspection, CLI activation, recent filtering, and sandbox scaffolding | project-launcher.md, ADR 0025 |
|
||||||
@ -86,6 +88,7 @@ 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).
|
||||||
|
- **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.
|
||||||
- **Viewport shading** (toolbar): **Lit** (default WYSIWYG), **Unlit** (base-color debug), **Colliders** (hide meshes, emphasize collider gizmos). The viewport combines active shading/render path in one bottom-right chip, draws a camera-relative X/Y/Z orientation widget, identifies the primary selection in a responsive top-right HUD, and previews asset placement with a pointer card, placement reticle, corner brackets, and highlighted drop boundary.
|
- **Viewport shading** (toolbar): **Lit** (default WYSIWYG), **Unlit** (base-color debug), **Colliders** (hide meshes, emphasize collider gizmos). The viewport combines active shading/render path in one bottom-right chip, draws a camera-relative X/Y/Z orientation widget, identifies the primary selection in a responsive top-right HUD, and previews asset placement with a pointer card, placement reticle, corner brackets, and highlighted drop boundary.
|
||||||
|
|||||||
@ -29,6 +29,7 @@ Native-only dependencies (`rfd`, BRP HTTP) stay in `editor`. `settings`, `shared
|
|||||||
| `ActorIconsPlugin` | Selectable actor root billboard icons |
|
| `ActorIconsPlugin` | Selectable actor root billboard icons |
|
||||||
| `EditorVisualizersPlugin` | Collider/light/spawn/prefab/runtime visualizers and selectable proxies |
|
| `EditorVisualizersPlugin` | Collider/light/spawn/prefab/runtime visualizers and selectable proxies |
|
||||||
| `SceneIoPlugin` | Level New/Open/Save, recent paths |
|
| `SceneIoPlugin` | Level New/Open/Save, recent paths |
|
||||||
|
| `CollaborationPlugin` | Exact authored-file guards, asynchronous Git status, optional ownership providers |
|
||||||
| `OperatorPlugin` | Active operator lifecycle/status for commands and modal tools |
|
| `OperatorPlugin` | Active operator lifecycle/status for commands and modal tools |
|
||||||
| `SettingsUiPlugin` | egui Project Settings panel |
|
| `SettingsUiPlugin` | egui Project Settings panel |
|
||||||
| `EditorUiPlugin` | Dock, themed egui shell, viewport overlays, menus, status bar |
|
| `EditorUiPlugin` | Dock, themed egui shell, viewport overlays, menus, status bar |
|
||||||
@ -76,6 +77,15 @@ The bounded in-session scene I/O event log feeds the Diagnostics window so a lat
|
|||||||
does not erase a save or recovery failure.
|
does not erase a save or recovery failure.
|
||||||
See [ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
|
See [ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
|
||||||
|
|
||||||
|
`project/collaboration.rs` wraps user-authored publication with BLAKE3 baselines and a second
|
||||||
|
revision/read-only check immediately before the same-directory rename. Scene tabs, prefab source
|
||||||
|
Apply/history, staged Material documents, and Project Settings use this shared boundary. Its worker
|
||||||
|
observes path-scoped Git porcelain and optional ownership providers without blocking the frame loop
|
||||||
|
or changing repository state; conflict recovery is explicit Reload, metadata comparison, Save As,
|
||||||
|
or Cancel. Generated artifacts remain under their subsystem-owned regeneration policy. See
|
||||||
|
[collaborative-file-safety.md](collaborative-file-safety.md) and
|
||||||
|
[ADR 0037](../adr/0037-collaborative-authored-file-safety.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 `AppExit`. Abnormal sessions do
|
||||||
@ -151,7 +161,7 @@ The egui layer lives under `crates/editor/src/ui/`:
|
|||||||
| `diagnostics.rs` | Detailed stats (Window → Diagnostics, Asset Browser footer) |
|
| `diagnostics.rs` | Detailed stats (Window → Diagnostics, Asset Browser footer) |
|
||||||
| `layout.rs` | Dock layout RON persistence in `editor_prefs.ron` |
|
| `layout.rs` | Dock layout RON persistence in `editor_prefs.ron` |
|
||||||
|
|
||||||
The Viewport fills the tab (no inline help text). Gizmo/grid/play controls sit on a near-black semi-transparent overlay, and `G` hides editor-only overlays for a clean game view. The fixed dark status strip prioritizes `SceneIo.status` feedback and also shows scene path/dirty state, mode, selection count, history, and the active operator. Long feedback is truncated responsively and available in a hover tooltip. Dock layout restores from `~/.config/bevy-fps/editor_prefs.ron` on startup; **View → Reset Layout** restores defaults.
|
The Viewport fills the tab (no inline help text). Gizmo/grid/play controls sit on a near-black semi-transparent overlay, and `G` hides editor-only overlays for a clean game view. The fixed dark status strip prioritizes `SceneIo.status` feedback and also shows scene path/dirty state, compact active-file collaboration status, mode, selection count, history, and the active operator. Long feedback is truncated responsively and available in a hover tooltip. Dock layout restores from `~/.config/bevy-fps/editor_prefs.ron` on startup; **View → Reset Layout** restores defaults.
|
||||||
|
|
||||||
**Follow-up (Phase 4b):** inspector search and richer per-asset previews.
|
**Follow-up (Phase 4b):** inspector search and richer per-asset previews.
|
||||||
|
|
||||||
@ -247,7 +257,7 @@ PIE stop restores player simulation state only; authored `LevelObject` edits mad
|
|||||||
| `viewport/` | Camera, selection, gizmos, render views, panel settings |
|
| `viewport/` | Camera, selection, gizmos, render views, panel settings |
|
||||||
| `play/` | PIE session, editor mode, net editor profiles |
|
| `play/` | PIE session, editor mode, net editor profiles |
|
||||||
| `assets/` | Catalog, asset DB, static mesh artifacts, prefab overrides |
|
| `assets/` | Catalog, asset DB, static mesh artifacts, prefab overrides |
|
||||||
| `project/` | Project I/O and settings UI |
|
| `project/` | Project I/O, settings UI, authored-file safety, and observational collaboration status |
|
||||||
| `ext/` | Command palette, BRP, game panel adapters |
|
| `ext/` | Command palette, BRP, game panel adapters |
|
||||||
| `history/` | Undo commands + plugin |
|
| `history/` | Undo commands + plugin |
|
||||||
| `ui/` | egui dock shell |
|
| `ui/` | egui dock shell |
|
||||||
|
|||||||
58
docs/editor/collaborative-file-safety.md
Normal file
58
docs/editor/collaborative-file-safety.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Collaborative File Safety
|
||||||
|
|
||||||
|
Blacksite protects loaded user-authored documents with exact content revisions. This layer covers
|
||||||
|
scene and prefab documents, editable Material and Material Instance assets, and `assets/project.ron`.
|
||||||
|
Generated import artifacts, registries, thumbnails, navigation bakes, recovery snapshots, and package output keep
|
||||||
|
their subsystem-owned regeneration rules and never interrupt editing with authored-file conflicts.
|
||||||
|
|
||||||
|
## Status Surfaces
|
||||||
|
|
||||||
|
The bottom status strip shows a compact state for the active saved scene. The selected asset's
|
||||||
|
Details header shows the same state when the asset has an on-disk source. Hover either indicator for
|
||||||
|
the path, Git state, read-only state, ownership detail, and scanner diagnostics.
|
||||||
|
|
||||||
|
When Git is available, Blacksite distinguishes clean, modified, untracked, and conflicted files.
|
||||||
|
The path-scoped scanner uses only `rev-parse` and NUL-delimited `status --porcelain`; it never
|
||||||
|
stages, commits, resets, checks out, restores, or discards files. A missing Git executable or
|
||||||
|
project outside a repository is a normal quiet state.
|
||||||
|
|
||||||
|
## Save Conflicts
|
||||||
|
|
||||||
|
On load, the editor records a BLAKE3 revision and file metadata. Save verifies that revision before
|
||||||
|
preparing the atomic replacement and again immediately before rename. A changed, newly created, or
|
||||||
|
read-only target remains untouched and opens the collaborative-file dialog.
|
||||||
|
|
||||||
|
The dialog provides:
|
||||||
|
|
||||||
|
- **Reload**: discard the editor draft for that document and load the current disk revision.
|
||||||
|
- **Compare Metadata**: show expected/current revision, byte size, modified time, and permissions.
|
||||||
|
- **Save As**: preserve the editor draft at a separately chosen path, guarded against a create race.
|
||||||
|
- **Cancel**: keep the editor draft and make no filesystem change.
|
||||||
|
|
||||||
|
There is intentionally no force-overwrite action. Resolve or preserve the other revision first.
|
||||||
|
|
||||||
|
## Ownership Providers
|
||||||
|
|
||||||
|
Teams can register an implementation of `FileOwnershipProvider` with
|
||||||
|
`register_file_ownership_provider`. Providers receive the active project root and a bounded list of
|
||||||
|
currently tracked project-relative paths on the background scan worker. They return owner/lock
|
||||||
|
details per path.
|
||||||
|
|
||||||
|
A lock held by another user blocks authored publication and uses the same recovery dialog. Provider
|
||||||
|
errors appear only in indicator details; the exact filesystem revision guard continues to work. An
|
||||||
|
empty provider registry performs no calls and adds no UI.
|
||||||
|
|
||||||
|
## Recovery Notes
|
||||||
|
|
||||||
|
- Scene Reload explicitly replaces the active dirty document with disk content and clears its local
|
||||||
|
history. Use Save As first when both versions matter.
|
||||||
|
- Material Reload discards only the staged Details draft and reloads the asset on the next draw.
|
||||||
|
- Project Settings Reload strictly parses the current manifest, reapplies it live, and refreshes the
|
||||||
|
panel draft; invalid external RON stays blocked and visible.
|
||||||
|
- Prefab Apply conflicts keep instance overrides intact. Reload tears down stale hydration and
|
||||||
|
queues the current source for rehydration; retry Apply after reviewing Changed Base/conflict
|
||||||
|
state, or Save As to preserve the proposed source copy.
|
||||||
|
- Prefab source undo/redo stays blocked when the exact expected source revision is absent.
|
||||||
|
|
||||||
|
The architectural policy is recorded in
|
||||||
|
[ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
|
||||||
@ -147,7 +147,7 @@ with implementation sequencing in
|
|||||||
| Milestone | Exit condition | Status |
|
| Milestone | Exit condition | Status |
|
||||||
|-----------|----------------|--------|
|
|-----------|----------------|--------|
|
||||||
| M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance |
|
| M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance |
|
||||||
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#48 are complete. Source-control safety #49 and final readiness gate #50 remain; packaged acceptance is deferred until requested by the project owner. |
|
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#48 are complete. #49 implementation and source/headless acceptance are complete, with live visual acceptance pending; final readiness gate #50 remains. Packaged acceptance is deferred until requested by the project owner. |
|
||||||
|
|
||||||
Production readiness is not inferred from feature count. Gitea
|
Production readiness is not inferred from feature count. Gitea
|
||||||
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)
|
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user