Add collaborative authored-file safety

This commit is contained in:
Rbanh 2026-07-12 11:27:57 -04:00
parent b4aa61e394
commit 5a82a9ee29
23 changed files with 2857 additions and 89 deletions

View File

@ -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

View File

@ -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.

View File

@ -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))

View File

@ -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| &current.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();

View File

@ -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 &current != 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) {

View File

@ -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);

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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 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>();
if let Err(error) = save_project_settings(&settings, &mut io) {
warn!("Save project settings failed: {error}");
} else {
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);
match read_project_settings_document(Path::new(io_path)) {
Ok((settings, source, snapshot)) => {
*draft = settings;
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;
}
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();
}
}

View File

@ -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(())

View File

@ -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(),

View File

@ -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 {

View File

@ -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>,
}

View File

@ -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;
}

View File

@ -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 => {

View File

@ -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#"(

View File

@ -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(())
}

View File

@ -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)

View 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.

View File

@ -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.

View File

@ -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 |

View 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).

View File

@ -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)