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.
|
||||
4. Completed: authoritative content validation `#45` and the validation-gated packaging/Build
|
||||
workflow `#44`.
|
||||
5. Completed: audio source/listener/bus authoring and spatial preview passed production acceptance
|
||||
in `#47`. Next: animation `#46`, navigation `#48`, source-control safety `#49`, then readiness
|
||||
gate `#50`.
|
||||
5. Completed: audio `#47`, animation `#46`, and navigation `#48` passed production acceptance.
|
||||
Source-control safety `#49` has complete implementation and source/headless acceptance; live
|
||||
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
|
||||
|
||||
|
||||
@ -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 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@ -261,6 +263,13 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
|
||||
without changing the scene.
|
||||
- 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.
|
||||
- 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
|
||||
`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
|
||||
@ -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,
|
||||
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
|
||||
|
||||
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 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 0037: Collaborative Authored-File Safety](docs/adr/0037-collaborative-authored-file-safety.md)
|
||||
|
||||
## 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] 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] 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] 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))
|
||||
|
||||
@ -1248,6 +1248,10 @@ fn apply_prefab_scope_to_source(
|
||||
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) {
|
||||
Ok(document) => document,
|
||||
Err(error) => {
|
||||
@ -1361,7 +1365,13 @@ fn apply_prefab_scope_to_source(
|
||||
}
|
||||
let after_source = document.to_ron_text()?;
|
||||
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)
|
||||
})();
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
let Some((instance_root, target)) = prefab_member_override_target(world, entity) else {
|
||||
ui.label(egui::RichText::new("Prefab target unavailable").color(ERROR));
|
||||
@ -3258,6 +3284,38 @@ mod tests {
|
||||
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]
|
||||
fn nested_unpack_fold_is_transactional_when_a_later_link_is_invalid() {
|
||||
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) {
|
||||
let candidate = world.resource::<EditorHistory>().undo_stack.last().cloned();
|
||||
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::<SceneIo>()
|
||||
@ -1591,7 +1591,7 @@ pub fn apply_command_undo(world: &mut World) {
|
||||
pub fn apply_command_redo(world: &mut World) {
|
||||
let candidate = world.resource::<EditorHistory>().redo_stack.last().cloned();
|
||||
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::<SceneIo>()
|
||||
@ -1609,8 +1609,13 @@ pub fn apply_command_redo(world: &mut 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 {
|
||||
entity,
|
||||
source_path,
|
||||
before_source,
|
||||
after_source,
|
||||
@ -1624,15 +1629,18 @@ fn prepare_prefab_source_history(command: &EditorCommand, undo: bool) -> Result<
|
||||
} else {
|
||||
(before_source, after_source)
|
||||
};
|
||||
let current = std::fs::read_to_string(source_path)
|
||||
.map_err(|error| format!("could not read {}: {error}", source_path.display()))?;
|
||||
if ¤t != expected {
|
||||
return Err(format!(
|
||||
"{} changed outside this history command",
|
||||
source_path.display()
|
||||
));
|
||||
}
|
||||
crate::scene::recovery::atomic_write(source_path, replacement.as_bytes())
|
||||
let expected =
|
||||
crate::collaboration::FileSnapshot::from_loaded_bytes(source_path, expected.as_bytes());
|
||||
crate::collaboration::publish_authored_file(
|
||||
world,
|
||||
source_path,
|
||||
replacement.as_bytes(),
|
||||
&expected,
|
||||
crate::collaboration::FileWriteIntent::PrefabSourceHistory {
|
||||
instance_root: *entity,
|
||||
},
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn undo_command(world: &mut World, command: &mut EditorCommand) {
|
||||
|
||||
@ -22,6 +22,7 @@ pub use ext::extensibility;
|
||||
pub use ext::hot_reload;
|
||||
pub use play::net_editor;
|
||||
pub use play::state;
|
||||
pub use project::collaboration;
|
||||
pub use project::diagnostics_bundle;
|
||||
pub use project::launcher;
|
||||
pub use project::project_io;
|
||||
@ -64,6 +65,7 @@ use infra::EditorInfraPlugin;
|
||||
use operators::OperatorPlugin;
|
||||
use play::audio_preview::AudioPreviewPlugin;
|
||||
use play::PlaySessionPlugin;
|
||||
use project::collaboration::CollaborationPlugin;
|
||||
use project_io::ProjectIoPlugin;
|
||||
use render_view::RenderViewPlugin;
|
||||
use scene_io::SceneIoPlugin;
|
||||
@ -111,6 +113,7 @@ impl PluginGroup for EditorPluginGroup {
|
||||
.add(EditorVisualizersPlugin)
|
||||
.add(EditorHistoryPlugin)
|
||||
.add(SceneIoPlugin)
|
||||
.add(CollaborationPlugin)
|
||||
.add(EditorUiPlugin)
|
||||
.add(EditorSessionPlugin)
|
||||
.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.
|
||||
|
||||
pub mod collaboration;
|
||||
pub mod diagnostics_bundle;
|
||||
pub mod launcher;
|
||||
pub mod project_io;
|
||||
|
||||
@ -1,18 +1,28 @@
|
||||
//! Editor-only Project Settings panel (egui). Data lives in the `settings` crate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::egui;
|
||||
use game_hot::sync_project_sun_from_settings;
|
||||
use settings::{
|
||||
load_project_settings_from_path, save_project_settings, sync_sim_tuning, ExposureMode,
|
||||
ProjectSettings, ProjectSettingsChanged, ProjectSettingsIo,
|
||||
save_project_settings_to_string, sync_sim_tuning, ExposureMode, ProjectSettings,
|
||||
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).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ProjectSettingsPanel {
|
||||
pub open: bool,
|
||||
pub draft: Option<ProjectSettings>,
|
||||
disk_snapshot: Option<FileSnapshot>,
|
||||
save_error: Option<String>,
|
||||
}
|
||||
|
||||
pub struct SettingsUiPlugin;
|
||||
@ -39,11 +49,23 @@ pub fn project_settings_window(
|
||||
) {
|
||||
if !panel.open {
|
||||
panel.draft = None;
|
||||
panel.disk_snapshot = None;
|
||||
panel.save_error = None;
|
||||
return;
|
||||
}
|
||||
|
||||
if panel.draft.is_none() {
|
||||
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;
|
||||
@ -51,17 +73,33 @@ pub fn project_settings_window(
|
||||
.open(&mut open)
|
||||
.default_width(420.0)
|
||||
.show(ctx, |ui| {
|
||||
let Some(draft) = panel.draft.as_mut() else {
|
||||
let ProjectSettingsPanel {
|
||||
draft: Some(draft),
|
||||
disk_snapshot,
|
||||
save_error,
|
||||
..
|
||||
} = panel
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let io_path = world.resource::<ProjectSettingsIo>().path.clone();
|
||||
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;
|
||||
});
|
||||
panel.open = open;
|
||||
if !open {
|
||||
panel.draft = None;
|
||||
panel.disk_snapshot = None;
|
||||
panel.save_error = None;
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,12 +109,26 @@ fn draw_project_settings_form(
|
||||
draft: &mut ProjectSettings,
|
||||
io_path: &str,
|
||||
io_dirty: &mut bool,
|
||||
disk_snapshot: &mut Option<FileSnapshot>,
|
||||
save_error: &mut Option<String>,
|
||||
) {
|
||||
let caps = world
|
||||
.get_resource::<settings::RenderingCapabilities>()
|
||||
.cloned()
|
||||
.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| {
|
||||
egui::ComboBox::from_label("GiMode")
|
||||
.selected_text(format!("{:?}", draft.rendering.gi_mode))
|
||||
@ -335,25 +387,116 @@ fn draw_project_settings_form(
|
||||
.clicked()
|
||||
{
|
||||
commit_project_settings(world, draft);
|
||||
*io_dirty = true;
|
||||
let settings = world.resource::<ProjectSettings>().clone();
|
||||
let mut io = world.resource_mut::<ProjectSettingsIo>();
|
||||
if let Err(error) = save_project_settings(&settings, &mut io) {
|
||||
warn!("Save project settings failed: {error}");
|
||||
} else {
|
||||
*io_dirty = false;
|
||||
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>();
|
||||
io.dirty = false;
|
||||
io.loaded_source = Some(source);
|
||||
*disk_snapshot = Some(snapshot);
|
||||
*save_error = None;
|
||||
*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() {
|
||||
*draft = load_project_settings_from_path(io_path);
|
||||
commit_project_settings(world, draft);
|
||||
world.resource_mut::<ProjectSettingsIo>().dirty = false;
|
||||
*io_dirty = false;
|
||||
match read_project_settings_document(Path::new(io_path)) {
|
||||
Ok((settings, source, snapshot)) => {
|
||||
*draft = settings;
|
||||
commit_project_settings(world, draft);
|
||||
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;
|
||||
}
|
||||
Err(error) => {
|
||||
*save_error = Some(error.clone());
|
||||
world
|
||||
.resource_mut::<SceneIo>()
|
||||
.set_status(format!("Revert project settings failed: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if *io_dirty {
|
||||
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) {
|
||||
let previous = world.resource::<ProjectSettings>().rendering.gi_mode;
|
||||
*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`
|
||||
// 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(()))
|
||||
}
|
||||
|
||||
fn atomic_write_with_pre_rename(
|
||||
pub(crate) fn atomic_write_with_pre_rename<E>(
|
||||
path: &Path,
|
||||
bytes: &[u8],
|
||||
before_rename: impl FnOnce() -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
before_rename: impl FnOnce() -> Result<(), E>,
|
||||
) -> Result<(), E>
|
||||
where
|
||||
E: From<String>,
|
||||
{
|
||||
let parent = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
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
|
||||
.file_name()
|
||||
@ -40,18 +43,21 @@ fn atomic_write_with_pre_rename(
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("could not create {}: {error}", temporary.display()))?;
|
||||
file.write_all(bytes)
|
||||
.map_err(|error| format!("could not write {}: {error}", temporary.display()))?;
|
||||
.map_err(|error| {
|
||||
E::from(format!("could not create {}: {error}", temporary.display()))
|
||||
})?;
|
||||
file.write_all(bytes).map_err(|error| {
|
||||
E::from(format!("could not write {}: {error}", temporary.display()))
|
||||
})?;
|
||||
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()?;
|
||||
fs::rename(&temporary, path).map_err(|error| {
|
||||
format!(
|
||||
E::from(format!(
|
||||
"could not replace {} with {}: {error}",
|
||||
path.display(),
|
||||
temporary.display()
|
||||
)
|
||||
))
|
||||
})?;
|
||||
sync_parent_directory(parent);
|
||||
Ok(())
|
||||
|
||||
@ -23,8 +23,9 @@ use shared::{
|
||||
|
||||
use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS};
|
||||
use crate::history::{clear_level_objects, snapshot_entity, EditorHistory};
|
||||
use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent};
|
||||
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,
|
||||
};
|
||||
use crate::selection::SelectedEntity;
|
||||
@ -60,6 +61,7 @@ pub struct SceneTab {
|
||||
pub dirty: bool,
|
||||
snapshot: String,
|
||||
recovery_snapshot: Option<PathBuf>,
|
||||
disk_snapshot: Option<FileSnapshot>,
|
||||
}
|
||||
|
||||
impl SceneTab {
|
||||
@ -126,6 +128,7 @@ impl Default for SceneIo {
|
||||
dirty: false,
|
||||
snapshot: String::new(),
|
||||
recovery_snapshot: None,
|
||||
disk_snapshot: None,
|
||||
}],
|
||||
active_tab: 0,
|
||||
change_revision: 0,
|
||||
@ -252,9 +255,14 @@ fn load_startup_scene(world: &mut World) {
|
||||
let path = active_path.unwrap_or(default_path);
|
||||
|
||||
match load_level(world, &path) {
|
||||
Ok(()) => {
|
||||
world.resource_mut::<SceneIo>().active_path = Some(path.clone());
|
||||
world.resource_mut::<SceneIo>().mark_clean();
|
||||
Ok(disk_snapshot) => {
|
||||
{
|
||||
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());
|
||||
world.resource_mut::<EditorHistory>().clear();
|
||||
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}");
|
||||
}
|
||||
match load_level(world, &path) {
|
||||
Ok(()) => {
|
||||
Ok(disk_snapshot) => {
|
||||
let id = world.resource_mut::<SceneIo>().allocate_tab_id();
|
||||
{
|
||||
let mut io = world.resource_mut::<SceneIo>();
|
||||
@ -406,6 +414,7 @@ fn open_path(world: &mut World, path: PathBuf) -> String {
|
||||
dirty: false,
|
||||
snapshot: String::new(),
|
||||
recovery_snapshot: None,
|
||||
disk_snapshot: Some(disk_snapshot),
|
||||
});
|
||||
io.active_tab = io.tabs.len() - 1;
|
||||
io.active_path = Some(path.clone());
|
||||
@ -442,6 +451,7 @@ fn new_scene_tab(world: &mut World) -> String {
|
||||
dirty: false,
|
||||
snapshot: String::new(),
|
||||
recovery_snapshot: None,
|
||||
disk_snapshot: None,
|
||||
});
|
||||
io.active_tab = io.tabs.len() - 1;
|
||||
io.active_path = None;
|
||||
@ -550,6 +560,7 @@ fn close_scene_tab(world: &mut World, index: usize) -> String {
|
||||
dirty: false,
|
||||
snapshot: String::new(),
|
||||
recovery_snapshot: None,
|
||||
disk_snapshot: None,
|
||||
}];
|
||||
io.active_tab = 0;
|
||||
io.active_path = None;
|
||||
@ -691,12 +702,24 @@ fn save_selection_as_prefab(world: &mut World) -> String {
|
||||
else {
|
||||
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 {
|
||||
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) => {
|
||||
world.resource_mut::<EditorAssets>().refresh();
|
||||
format!(
|
||||
@ -775,6 +798,7 @@ fn save_prefab_entities(
|
||||
world: &mut World,
|
||||
path: &Path,
|
||||
entities: Vec<Entity>,
|
||||
write_context: SceneWriteContext,
|
||||
) -> Result<usize, String> {
|
||||
let count = entities.len();
|
||||
let original_actor_ids: Vec<_> = entities
|
||||
@ -791,7 +815,7 @@ fn save_prefab_entities(
|
||||
.clone(),
|
||||
);
|
||||
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)
|
||||
})();
|
||||
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 {
|
||||
let path = world.resource::<SceneIo>().active_path.clone();
|
||||
match path {
|
||||
Some(path) => match save_level(world, &path) {
|
||||
Some(path) => match save_level(world, &path, SceneWriteContext::Active) {
|
||||
Ok(count) => {
|
||||
world.resource_mut::<SceneIo>().mark_clean();
|
||||
remember_path(world, path.clone());
|
||||
@ -843,8 +867,12 @@ fn save_with_dialog(world: &mut World) -> String {
|
||||
else {
|
||||
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) => {
|
||||
world.resource_mut::<SceneIo>().active_path = Some(path.clone());
|
||||
world.resource_mut::<SceneIo>().mark_clean();
|
||||
@ -901,8 +929,20 @@ fn export_selection_with_dialog(world: &mut World) -> String {
|
||||
else {
|
||||
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()),
|
||||
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);
|
||||
if is_prefab_document(path) {
|
||||
save_prefab_entities(world, path, entities)
|
||||
save_prefab_entities(world, path, entities, write_context)
|
||||
} 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 text = serialize_entities(world, entities)?;
|
||||
atomic_write(path, text.as_bytes())?;
|
||||
publish_scene_text(world, path, text.as_bytes(), count, write_context)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
@ -1142,13 +1191,108 @@ fn save_standalone_entities(
|
||||
world: &mut World,
|
||||
path: &Path,
|
||||
entities: Vec<Entity>,
|
||||
write_context: SceneWriteContext,
|
||||
) -> Result<usize, String> {
|
||||
let count = entities.len();
|
||||
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)
|
||||
}
|
||||
|
||||
#[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> {
|
||||
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();
|
||||
};
|
||||
match load_level(world, &snapshot) {
|
||||
Ok(()) => {
|
||||
Ok(_) => {
|
||||
mark_recovery_restored(&mut world.resource_mut::<SceneIo>(), active_path);
|
||||
world.resource_mut::<EditorHistory>().clear();
|
||||
format!(
|
||||
@ -1471,8 +1615,12 @@ fn save_recovery_copy_with_dialog(world: &mut World) -> String {
|
||||
else {
|
||||
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!(
|
||||
"Saved recovery copy {} from {}",
|
||||
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)
|
||||
.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>) {
|
||||
@ -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() {
|
||||
return Err(format!("{} does not exist yet", path.display()));
|
||||
}
|
||||
|
||||
let text = std::fs::read_to_string(path)
|
||||
.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> {
|
||||
@ -1979,6 +2206,7 @@ mod tests {
|
||||
dirty: true,
|
||||
snapshot: "dirty inactive tab".to_string(),
|
||||
recovery_snapshot: None,
|
||||
disk_snapshot: None,
|
||||
});
|
||||
assert!(
|
||||
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]
|
||||
fn composition_edits_participate_in_undo_and_redo() {
|
||||
let mut app = App::new();
|
||||
@ -2770,8 +3053,13 @@ mod tests {
|
||||
.insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());
|
||||
app.world_mut().init_resource::<SceneIo>();
|
||||
|
||||
load_level(app.world_mut(), &prefab_path).unwrap();
|
||||
save_level(app.world_mut(), &prefab_path).unwrap();
|
||||
let expected = load_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();
|
||||
let document =
|
||||
@ -2813,8 +3101,11 @@ mod tests {
|
||||
let destination = root.join("kept.scn.ron");
|
||||
std::fs::create_dir_all(&root).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!(
|
||||
std::fs::read(&destination).unwrap(),
|
||||
|
||||
@ -20,6 +20,10 @@ use crate::assets::{
|
||||
AssetSelection, AssetSubAssetKind, EditorAsset, EditorAssetKind, EditorAssets, ASSETS_ROOT,
|
||||
BUILTINS_FOLDER,
|
||||
};
|
||||
use crate::project::collaboration::{
|
||||
file_status_indicator_ui, publish_authored_file, CollaborationState, FileSnapshot,
|
||||
FileWriteIntent,
|
||||
};
|
||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||
|
||||
use super::state::{
|
||||
@ -1857,7 +1861,7 @@ fn top_level_asset_details_panel(
|
||||
selected_entities: &SelectedEntities,
|
||||
asset: &EditorAsset,
|
||||
) {
|
||||
asset_details_header(ui, asset);
|
||||
asset_details_header(world, ui, asset);
|
||||
ui.add_space(8.0);
|
||||
detail_row(ui, "Path", asset.path.as_deref().unwrap_or("Built-in"));
|
||||
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);
|
||||
}
|
||||
|
||||
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.label(
|
||||
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.strong(asset.label.as_str());
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
let draft = match MaterialAsset::load_from_path(path) {
|
||||
Ok(asset) => MaterialAssetDraft {
|
||||
let loaded = fs::read_to_string(path)
|
||||
.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(),
|
||||
asset,
|
||||
disk_snapshot,
|
||||
error: None,
|
||||
},
|
||||
Err(error) => MaterialAssetDraft {
|
||||
@ -2450,6 +2470,8 @@ fn ensure_material_draft(world: &mut World, path: &str, fallback_label: &str) {
|
||||
render_state: shared::MaterialRenderState::default(),
|
||||
material: MaterialDesc::default(),
|
||||
},
|
||||
disk_snapshot: FileSnapshot::capture(Path::new(path))
|
||||
.unwrap_or_else(|_| FileSnapshot::missing()),
|
||||
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())
|
||||
.map_err(|error| format!("could not serialize material {}: {error}", draft.path))
|
||||
.and_then(|text| {
|
||||
fs::write(&draft.path, text)
|
||||
.map_err(|error| format!("could not write material {}: {error}", draft.path))
|
||||
publish_authored_file(
|
||||
world,
|
||||
Path::new(&draft.path),
|
||||
text.as_bytes(),
|
||||
&draft.disk_snapshot,
|
||||
FileWriteIntent::Material,
|
||||
)
|
||||
});
|
||||
match result {
|
||||
Ok(()) => {
|
||||
Ok(disk_snapshot) => {
|
||||
if let Some(current) = world
|
||||
.resource_mut::<AssetBrowserUiState>()
|
||||
.material_draft
|
||||
.as_mut()
|
||||
{
|
||||
current.error = None;
|
||||
current.disk_snapshot = disk_snapshot;
|
||||
}
|
||||
world.resource_mut::<EditorAssets>().refresh();
|
||||
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())
|
||||
.map_err(|error| format!("could not serialize material instance: {error}"))
|
||||
.and_then(|text| {
|
||||
fs::write(&path, text)
|
||||
.map_err(|error| format!("could not write {}: {error}", path.display()))
|
||||
publish_authored_file(
|
||||
world,
|
||||
&path,
|
||||
text.as_bytes(),
|
||||
&FileSnapshot::missing(),
|
||||
FileWriteIntent::MaterialInstance,
|
||||
)
|
||||
});
|
||||
if let Err(error) = result {
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
let draft = match MaterialInstanceAsset::load_from_path(path) {
|
||||
Ok(asset) => MaterialInstanceAssetDraft {
|
||||
let loaded = fs::read_to_string(path)
|
||||
.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(),
|
||||
asset,
|
||||
disk_snapshot,
|
||||
error: None,
|
||||
},
|
||||
Err(error) => MaterialInstanceAssetDraft {
|
||||
@ -2860,6 +2902,8 @@ fn ensure_material_instance_draft(world: &mut World, path: &str, fallback_label:
|
||||
parameters: Vec::new(),
|
||||
textures: Vec::new(),
|
||||
},
|
||||
disk_snapshot: FileSnapshot::capture(Path::new(path))
|
||||
.unwrap_or_else(|_| FileSnapshot::missing()),
|
||||
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())
|
||||
.map_err(|error| format!("could not serialize material instance: {error}"))
|
||||
.and_then(|text| {
|
||||
fs::write(&draft.path, text)
|
||||
.map_err(|error| format!("could not write {}: {error}", draft.path))
|
||||
publish_authored_file(
|
||||
world,
|
||||
Path::new(&draft.path),
|
||||
text.as_bytes(),
|
||||
&draft.disk_snapshot,
|
||||
FileWriteIntent::MaterialInstance,
|
||||
)
|
||||
})
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
Ok(disk_snapshot) => {
|
||||
if let Some(current) = world
|
||||
.resource_mut::<AssetBrowserUiState>()
|
||||
.material_instance_draft
|
||||
.as_mut()
|
||||
{
|
||||
current.error = None;
|
||||
current.disk_snapshot = disk_snapshot;
|
||||
}
|
||||
world.resource_mut::<EditorAssets>().refresh();
|
||||
invalidate_on_catalog_refresh(world);
|
||||
@ -4380,10 +4430,181 @@ fn dedup_strings(values: &mut Vec<String>) {
|
||||
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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn audio_filter_matches_only_audio_clips() {
|
||||
let audio = EditorAsset {
|
||||
|
||||
@ -7,6 +7,7 @@ use shared::{MaterialAsset, MaterialInstanceAsset};
|
||||
|
||||
use crate::asset_db::ImportSettings;
|
||||
use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER};
|
||||
use crate::project::collaboration::FileSnapshot;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AssetBrowserView {
|
||||
@ -69,6 +70,7 @@ pub(crate) struct ImportSettingsDraft {
|
||||
pub(crate) struct MaterialAssetDraft {
|
||||
pub(crate) path: String,
|
||||
pub(crate) asset: MaterialAsset,
|
||||
pub(crate) disk_snapshot: FileSnapshot,
|
||||
pub(crate) error: Option<String>,
|
||||
}
|
||||
|
||||
@ -76,6 +78,7 @@ pub(crate) struct MaterialAssetDraft {
|
||||
pub(crate) struct MaterialInstanceAssetDraft {
|
||||
pub(crate) path: String,
|
||||
pub(crate) asset: MaterialInstanceAsset,
|
||||
pub(crate) disk_snapshot: FileSnapshot,
|
||||
pub(crate) error: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@ -39,6 +39,10 @@ use crate::project_io::UserPreferences;
|
||||
use crate::selection::SelectedEntity;
|
||||
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 diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
||||
pub use layout::LayoutSaveTimer;
|
||||
@ -215,6 +219,8 @@ impl UiState {
|
||||
project_settings_window(world, ctx, &mut panel);
|
||||
});
|
||||
|
||||
crate::collaboration::file_conflict_modal(world, ctx);
|
||||
|
||||
if ctx.input(|input| input.key_pressed(egui::Key::F1)) {
|
||||
world.resource_mut::<ViewportUiState>().shortcuts_open = true;
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ use egui_phosphor_icons::icons;
|
||||
|
||||
use crate::history::EditorHistory;
|
||||
use crate::operators::ActiveOperator;
|
||||
use crate::project::collaboration::{file_status_indicator_ui, CollaborationState};
|
||||
use crate::scene_io::SceneIo;
|
||||
use crate::state::{EditorMode, PlayPaused};
|
||||
|
||||
@ -47,6 +48,16 @@ pub fn status_bar_ui(
|
||||
.on_hover_text(status);
|
||||
},
|
||||
|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 {
|
||||
EditorMode::Editing => (icons::PENCIL_SIMPLE, "EDIT", ACCENT),
|
||||
EditorMode::Playing => {
|
||||
|
||||
@ -227,17 +227,22 @@ impl Default for InputSettings {
|
||||
|
||||
/// Loads settings from disk, falling back to defaults.
|
||||
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) {
|
||||
Ok(contents) => match ron::from_str(&contents) {
|
||||
Ok(settings) => settings,
|
||||
Ok(settings) => (settings, Some(contents)),
|
||||
Err(error) => {
|
||||
warn!("Failed to parse {path}: {error}; using defaults");
|
||||
ProjectSettings::default()
|
||||
(ProjectSettings::default(), Some(contents))
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn legacy_manifest_defaults_project_identity_fields() {
|
||||
let legacy = r#"(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@ -56,6 +56,8 @@ impl SimTuning {
|
||||
pub struct ProjectSettingsIo {
|
||||
pub path: String,
|
||||
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 {
|
||||
@ -63,6 +65,7 @@ impl Default for ProjectSettingsIo {
|
||||
Self {
|
||||
path: DEFAULT_PROJECT_PATH.into(),
|
||||
dirty: false,
|
||||
loaded_source: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -91,8 +94,9 @@ impl Plugin for ProjectSettingsPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_settings(mut commands: Commands, io: Res<ProjectSettingsIo>) {
|
||||
let settings = load_project_settings_from_path(&io.path);
|
||||
fn load_settings(mut commands: Commands, mut io: ResMut<ProjectSettingsIo>) {
|
||||
let (settings, source) = load_project_settings_with_source(&io.path);
|
||||
io.loaded_source = source;
|
||||
let tuning = SimTuning::from_physics(&settings.physics);
|
||||
commands.insert_resource(settings);
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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(
|
||||
settings: &ProjectSettings,
|
||||
io: &mut ProjectSettingsIo,
|
||||
) -> Result<(), 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;
|
||||
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 |
|
||||
| [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 |
|
||||
| [0037](adr/0037-collaborative-authored-file-safety.md) | Exact authored-file revisions, observational Git status, and optional ownership providers |
|
||||
|
||||
## 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/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/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/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 |
|
||||
| `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 |
|
||||
| `source_control_collaboration_safety_*.plan.md` | Exact authored-file guards, observational Git status, conflict recovery, and provider contract |
|
||||
|
||||
## 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 |
|
||||
| [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 |
|
||||
| [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/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 |
|
||||
| `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/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/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 |
|
||||
@ -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
|
||||
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).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@ -29,6 +29,7 @@ Native-only dependencies (`rfd`, BRP HTTP) stay in `editor`. `settings`, `shared
|
||||
| `ActorIconsPlugin` | Selectable actor root billboard icons |
|
||||
| `EditorVisualizersPlugin` | Collider/light/spawn/prefab/runtime visualizers and selectable proxies |
|
||||
| `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 |
|
||||
| `SettingsUiPlugin` | egui Project Settings panel |
|
||||
| `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.
|
||||
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
|
||||
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
|
||||
@ -151,7 +161,7 @@ The egui layer lives under `crates/editor/src/ui/`:
|
||||
| `diagnostics.rs` | Detailed stats (Window → Diagnostics, Asset Browser footer) |
|
||||
| `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.
|
||||
|
||||
@ -247,7 +257,7 @@ PIE stop restores player simulation state only; authored `LevelObject` edits mad
|
||||
| `viewport/` | Camera, selection, gizmos, render views, panel settings |
|
||||
| `play/` | PIE session, editor mode, net editor profiles |
|
||||
| `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 |
|
||||
| `history/` | Undo commands + plugin |
|
||||
| `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 |
|
||||
|-----------|----------------|--------|
|
||||
| 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
|
||||
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user