diff --git a/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md b/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md index fc1005c..a3ad794 100644 --- a/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md +++ b/.cursor/plans/blacksite_production_readiness_2026-07-10.plan.md @@ -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 diff --git a/.cursor/plans/source_control_collaboration_safety_2026-07-12.plan.md b/.cursor/plans/source_control_collaboration_safety_2026-07-12.plan.md new file mode 100644 index 0000000..39ee0d2 --- /dev/null +++ b/.cursor/plans/source_control_collaboration_safety_2026-07-12.plan.md @@ -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. diff --git a/README.md b/README.md index 7de8995..4473210 100644 --- a/README.md +++ b/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)) diff --git a/crates/editor/src/assets/prefab_overrides.rs b/crates/editor/src/assets/prefab_overrides.rs index 7fe03f0..257448f 100644 --- a/crates/editor/src/assets/prefab_overrides.rs +++ b/crates/editor/src/assets/prefab_overrides.rs @@ -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 { + if world.get::(instance_root).is_none() { + return Err("the prefab instance no longer exists".into()); + } + retry_prefab_source(world, instance_root); + let status = world.resource::().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::(instance_root) + .map(|current| ¤t.overrides_ron), + Some(&instance.overrides_ron) + ); + assert!(world.get::(instance_root).is_none()); + assert!(world.get::(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(); diff --git a/crates/editor/src/history/mod.rs b/crates/editor/src/history/mod.rs index 28c9836..ab37385 100644 --- a/crates/editor/src/history/mod.rs +++ b/crates/editor/src/history/mod.rs @@ -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::().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::().status = format!("Undo blocked: {error}"); world .resource_mut::() @@ -1591,7 +1591,7 @@ pub fn apply_command_undo(world: &mut World) { pub fn apply_command_redo(world: &mut World) { let candidate = world.resource::().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::().status = format!("Redo blocked: {error}"); world .resource_mut::() @@ -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) { diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 3470647..5618e11 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -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); diff --git a/crates/editor/src/project/collaboration.rs b/crates/editor/src/project/collaboration.rs new file mode 100644 index 0000000..23a5765 --- /dev/null +++ b/crates/editor/src/project/collaboration.rs @@ -0,0 +1,1731 @@ +//! Exact authored-file guards plus observational source-control and ownership status. + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use bevy::prelude::*; +use bevy_egui::egui; +use egui_phosphor_icons::icons; +use shared::PrefabInstance; + +use crate::assets::EditorAssets; +use crate::project_io::ProjectWorkspace; +use crate::scene::recovery::atomic_write_with_pre_rename; +use crate::scene_io::SceneIo; + +const STATUS_REFRESH_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileRevision { + Missing, + Present([u8; 32]), +} + +impl FileRevision { + pub fn short_label(&self) -> String { + match self { + Self::Missing => "missing".into(), + Self::Present(hash) => hash[..6].iter().map(|byte| format!("{byte:02x}")).collect(), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FileComparisonMetadata { + pub byte_len: Option, + pub modified_unix_millis: Option, + pub readonly: bool, +} + +impl FileComparisonMetadata { + fn from_metadata(metadata: &fs::Metadata) -> Self { + Self { + byte_len: Some(metadata.len()), + modified_unix_millis: metadata.modified().ok().and_then(system_time_millis), + readonly: metadata.permissions().readonly(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileSnapshot { + pub revision: FileRevision, + pub metadata: FileComparisonMetadata, +} + +impl FileSnapshot { + pub fn missing() -> Self { + Self { + revision: FileRevision::Missing, + metadata: FileComparisonMetadata::default(), + } + } + + pub fn capture(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => Ok(Self::from_loaded_bytes(path, &bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::missing()), + Err(error) => Err(format!("could not read {}: {error}", path.display())), + } + } + + pub fn from_loaded_bytes(path: &Path, bytes: &[u8]) -> Self { + let metadata = fs::metadata(path) + .ok() + .map(|metadata| FileComparisonMetadata::from_metadata(&metadata)) + .unwrap_or_else(|| FileComparisonMetadata { + byte_len: Some(bytes.len() as u64), + ..Default::default() + }); + Self { + revision: FileRevision::Present(*blake3::hash(bytes).as_bytes()), + metadata, + } + } +} + +fn system_time_millis(time: SystemTime) -> Option { + time.duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GuardedWriteError { + Changed { + expected: Box, + current: Box, + }, + ReadOnly { + expected: Box, + current: Box, + }, + Io(String), +} + +impl fmt::Display for GuardedWriteError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Changed { .. } => write!(formatter, "the file changed outside Blacksite"), + Self::ReadOnly { .. } => write!(formatter, "the file is read-only"), + Self::Io(error) => formatter.write_str(error), + } + } +} + +impl From for GuardedWriteError { + fn from(error: String) -> Self { + Self::Io(error) + } +} + +pub fn guarded_atomic_write( + path: &Path, + bytes: &[u8], + expected: &FileSnapshot, +) -> Result { + guarded_atomic_write_inner(path, bytes, expected, || {}) +} + +fn guarded_atomic_write_inner( + path: &Path, + bytes: &[u8], + expected: &FileSnapshot, + before_final_check: impl FnOnce(), +) -> Result { + verify_expected_revision(path, expected)?; + atomic_write_with_pre_rename(path, bytes, || { + before_final_check(); + verify_expected_revision(path, expected).map(|_| ()) + })?; + Ok(FileSnapshot::from_loaded_bytes(path, bytes)) +} + +fn verify_expected_revision( + path: &Path, + expected: &FileSnapshot, +) -> Result { + let current = FileSnapshot::capture(path).map_err(GuardedWriteError::Io)?; + if current.revision != expected.revision { + return Err(GuardedWriteError::Changed { + expected: Box::new(expected.clone()), + current: Box::new(current), + }); + } + if current.metadata.readonly { + return Err(GuardedWriteError::ReadOnly { + expected: Box::new(expected.clone()), + current: Box::new(current), + }); + } + Ok(current) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitFileState { + Clean, + Modified, + Untracked, + Conflicted, +} + +impl GitFileState { + fn priority(self) -> u8 { + match self { + Self::Clean => 0, + Self::Modified => 1, + Self::Untracked => 2, + Self::Conflicted => 3, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +enum GitAvailability { + Available, + #[default] + Unavailable, + NotRepository, + Error(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileOwnership { + pub path: PathBuf, + pub owner: Option, + pub locked_by_other: bool, + pub detail: Option, +} + +/// Optional vendor/team integration. Calls run on the collaboration worker thread. +pub trait FileOwnershipProvider: Send + Sync + 'static { + fn name(&self) -> &str; + + fn query( + &self, + project_root: &Path, + project_relative_paths: &[PathBuf], + ) -> Result, String>; +} + +#[derive(Resource, Default)] +pub struct FileOwnershipProviders { + providers: Vec>, +} + +impl FileOwnershipProviders { + pub fn register(&mut self, provider: Arc) { + self.providers.push(provider); + } + + pub fn register_typed(&mut self, provider: impl FileOwnershipProvider) { + self.register(Arc::new(provider)); + } + + pub fn is_empty(&self) -> bool { + self.providers.is_empty() + } +} + +pub fn register_file_ownership_provider(app: &mut App, provider: impl FileOwnershipProvider) { + app.init_resource::(); + app.world_mut() + .resource_mut::() + .register_typed(provider); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProviderFileState { + provider: String, + owner: Option, + locked_by_other: bool, + detail: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ObservedFileMetadata { + readonly: bool, +} + +#[derive(Debug, Clone, Default)] +struct CollaborationScan { + git_availability: GitAvailability, + git_files: HashMap, + ownership: HashMap>, + provider_errors: Vec, + observed_files: HashMap, +} + +struct CollaborationScanJob { + result: Arc>>, + worker: Option>, +} + +impl Drop for CollaborationScanJob { + fn drop(&mut self) { + if let Some(worker) = self.worker.take() { + // Provider integrations are expected to be bounded, but a faulty provider must not + // hold editor shutdown hostage. Dropping a live handle detaches the worker safely. + if worker.is_finished() { + let _ = worker.join(); + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum FileWriteIntent { + Scene { tab_id: u64, entity_count: usize }, + Material, + MaterialInstance, + ProjectSettings, + PrefabSource { instance_root: Entity }, + PrefabSourceHistory { instance_root: Entity }, + Standalone { description: String }, +} + +impl FileWriteIntent { + fn display_name(&self) -> &str { + match self { + Self::Scene { .. } => "scene", + Self::Material => "material", + Self::MaterialInstance => "material instance", + Self::ProjectSettings => "project settings", + Self::PrefabSource { .. } => "prefab source", + Self::PrefabSourceHistory { .. } => "prefab source history", + Self::Standalone { description } => description, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum FileConflictReason { + ExternalChange, + ReadOnly, + ProviderLocked { + provider: String, + owner: Option, + detail: Option, + }, +} + +#[derive(Debug, Clone)] +struct PendingFileConflict { + path: PathBuf, + expected: FileSnapshot, + current: FileSnapshot, + reason: FileConflictReason, + intent: FileWriteIntent, + proposed_bytes: Vec, + show_metadata: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FileCollaborationStatus { + pub git: Option, + pub readonly: bool, + pub owner: Option, + pub locked_by_other: bool, + pub provider: Option, + pub ownership_detail: Option, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileStatusTone { + Neutral, + Success, + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileStatusIndicator { + pub label: &'static str, + pub tone: FileStatusTone, +} + +impl FileCollaborationStatus { + pub fn indicator(&self) -> Option { + if self.locked_by_other { + return Some(FileStatusIndicator { + label: "LOCKED", + tone: FileStatusTone::Error, + }); + } + if self.readonly { + return Some(FileStatusIndicator { + label: "READ ONLY", + tone: FileStatusTone::Error, + }); + } + match self.git { + Some(GitFileState::Conflicted) => Some(FileStatusIndicator { + label: "CONFLICT", + tone: FileStatusTone::Error, + }), + Some(GitFileState::Modified) => Some(FileStatusIndicator { + label: "MODIFIED", + tone: FileStatusTone::Warning, + }), + Some(GitFileState::Untracked) => Some(FileStatusIndicator { + label: "UNTRACKED", + tone: FileStatusTone::Neutral, + }), + Some(GitFileState::Clean) => Some(FileStatusIndicator { + label: "CLEAN", + tone: FileStatusTone::Success, + }), + None if self.owner.is_some() => Some(FileStatusIndicator { + label: "OWNED", + tone: FileStatusTone::Neutral, + }), + None if !self.diagnostics.is_empty() => Some(FileStatusIndicator { + label: "STATUS", + tone: FileStatusTone::Warning, + }), + None => None, + } + } + + pub fn tooltip(&self, path: &Path) -> String { + let mut lines = vec![path.display().to_string()]; + if let Some(git) = self.git { + lines.push(format!( + "Git: {}", + match git { + GitFileState::Clean => "clean", + GitFileState::Modified => "modified", + GitFileState::Untracked => "untracked", + GitFileState::Conflicted => "conflicted", + } + )); + } + if self.readonly { + lines.push("Filesystem: read-only".into()); + } + if let Some(provider) = &self.provider { + let owner = self.owner.as_deref().unwrap_or("unassigned"); + let lock = if self.locked_by_other { + "locked by another owner" + } else { + "available" + }; + lines.push(format!("{provider}: {lock} ({owner})")); + } + if let Some(detail) = &self.ownership_detail { + lines.push(detail.clone()); + } + lines.extend(self.diagnostics.iter().cloned()); + lines.join("\n") + } +} + +pub(crate) fn file_status_indicator_ui( + ui: &mut egui::Ui, + status: &FileCollaborationStatus, + path: &Path, +) -> Option { + let indicator = status.indicator()?; + let color = match indicator.tone { + FileStatusTone::Neutral => crate::ui::theme::TEXT_DIM, + FileStatusTone::Success => crate::ui::theme::SUCCESS, + FileStatusTone::Warning => crate::ui::theme::WARNING, + FileStatusTone::Error => crate::ui::theme::ERROR, + }; + let icon = if status.locked_by_other || status.readonly { + icons::LOCK_SIMPLE + } else { + match status.git { + Some(GitFileState::Clean) => icons::CHECK_CIRCLE, + Some(GitFileState::Conflicted) => icons::WARNING, + _ => icons::GIT_BRANCH, + } + }; + let fill = egui::Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 18); + let response = egui::Frame::new() + .fill(fill) + .stroke(egui::Stroke::new(1.0, color.linear_multiply(0.55))) + .corner_radius(2.0) + .inner_margin(egui::Margin::symmetric(5, 1)) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(icon.as_str()) + .font(egui::FontId::new( + 12.0, + egui::FontFamily::Name("phosphor-regular".into()), + )) + .color(color), + ); + ui.label( + egui::RichText::new(indicator.label) + .small() + .strong() + .color(color), + ); + }); + }) + .response; + Some(response.on_hover_text(status.tooltip(path))) +} + +#[derive(Resource, Default)] +pub struct CollaborationState { + project_root: PathBuf, + tracked_paths: Vec, + git_availability: GitAvailability, + git_files: HashMap, + ownership: HashMap>, + provider_errors: Vec, + observed_files: HashMap, + scan_job: Option, + last_scan_started: Option, + pending_conflict: Option, +} + +impl CollaborationState { + pub fn has_pending_conflict(&self) -> bool { + self.pending_conflict.is_some() + } + + pub fn request_refresh(&mut self) { + self.last_scan_started = None; + } + + pub fn file_status(&self, path: &Path) -> FileCollaborationStatus { + let Some(relative) = project_relative_path(&self.project_root, path) else { + return FileCollaborationStatus::default(); + }; + let git = matches!(self.git_availability, GitAvailability::Available).then(|| { + self.git_files + .get(&relative) + .copied() + .unwrap_or(GitFileState::Clean) + }); + let ownership = self + .ownership + .get(&relative) + .and_then(|states| { + states + .iter() + .find(|state| state.locked_by_other) + .or_else(|| states.first()) + }) + .cloned(); + let mut diagnostics = self.provider_errors.clone(); + if let GitAvailability::Error(error) = &self.git_availability { + diagnostics.push(error.clone()); + } + FileCollaborationStatus { + git, + readonly: self + .observed_files + .get(&relative) + .is_some_and(|metadata| metadata.readonly), + owner: ownership.as_ref().and_then(|state| state.owner.clone()), + locked_by_other: ownership + .as_ref() + .is_some_and(|state| state.locked_by_other), + provider: ownership.as_ref().map(|state| state.provider.clone()), + ownership_detail: ownership.and_then(|state| state.detail), + diagnostics, + } + } +} + +pub struct CollaborationPlugin; + +impl Plugin for CollaborationPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .add_systems(Update, tick_collaboration_status); + } +} + +pub(crate) fn publish_authored_file( + world: &mut World, + path: &Path, + bytes: &[u8], + expected: &FileSnapshot, + intent: FileWriteIntent, +) -> Result { + if let Some((provider, owner, detail)) = world + .get_resource::() + .map(|state| state.file_status(path)) + .filter(|status| status.locked_by_other) + .map(|status| (status.provider, status.owner, status.ownership_detail)) + { + let current = FileSnapshot::capture(path)?; + let provider = provider.unwrap_or_else(|| "ownership provider".into()); + queue_conflict( + world, + PendingFileConflict { + path: path.to_path_buf(), + expected: expected.clone(), + current, + reason: FileConflictReason::ProviderLocked { + provider: provider.clone(), + owner, + detail, + }, + intent, + proposed_bytes: bytes.to_vec(), + show_metadata: false, + }, + ); + return Err(format!( + "{} is locked by another owner via {provider}", + path.display() + )); + } + + match guarded_atomic_write(path, bytes, expected) { + Ok(snapshot) => { + if let Some(mut state) = world.get_resource_mut::() { + state.request_refresh(); + } + Ok(snapshot) + } + Err(GuardedWriteError::Changed { expected, current }) => { + queue_conflict( + world, + PendingFileConflict { + path: path.to_path_buf(), + expected: *expected, + current: *current, + reason: FileConflictReason::ExternalChange, + intent, + proposed_bytes: bytes.to_vec(), + show_metadata: false, + }, + ); + Err(format!("{} changed outside Blacksite", path.display())) + } + Err(GuardedWriteError::ReadOnly { expected, current }) => { + queue_conflict( + world, + PendingFileConflict { + path: path.to_path_buf(), + expected: *expected, + current: *current, + reason: FileConflictReason::ReadOnly, + intent, + proposed_bytes: bytes.to_vec(), + show_metadata: false, + }, + ); + Err(format!("{} is read-only", path.display())) + } + Err(GuardedWriteError::Io(error)) => Err(error), + } +} + +enum ConflictUiAction { + Cancel, + ToggleMetadata, + Reload, + SaveAs, +} + +pub(crate) fn file_conflict_modal(world: &mut World, ctx: &egui::Context) { + let conflict = world + .get_resource::() + .and_then(|state| state.pending_conflict.clone()); + let Some(conflict) = conflict else { + return; + }; + + let mut action = None; + let response = egui::Modal::new(egui::Id::new("collaborative_file_conflict")).show(ctx, |ui| { + ui.set_width(480.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(icons::WARNING.as_str()) + .font(egui::FontId::new( + 22.0, + egui::FontFamily::Name("phosphor-regular".into()), + )) + .color(crate::ui::theme::WARNING), + ); + ui.heading("Authored File Not Saved"); + }); + ui.add_space(6.0); + ui.add( + egui::Label::new( + egui::RichText::new(conflict.path.display().to_string()) + .monospace() + .color(crate::ui::theme::TEXT), + ) + .wrap(), + ); + ui.add_space(8.0); + ui.label(conflict_reason_text(&conflict)); + ui.small( + egui::RichText::new("The existing file is unchanged.") + .color(crate::ui::theme::TEXT_DIM), + ); + + if conflict.show_metadata { + ui.add_space(10.0); + egui::Frame::new() + .fill(crate::ui::theme::PANEL_BG_DARK) + .stroke(egui::Stroke::new(1.0, crate::ui::theme::BORDER)) + .corner_radius(3.0) + .inner_margin(egui::Margin::same(9)) + .show(ui, |ui| { + metadata_comparison_ui(ui, &conflict); + }); + } + + ui.add_space(12.0); + ui.separator(); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + if ui.button(format!("{} Cancel", icons::X.as_str())).clicked() { + action = Some(ConflictUiAction::Cancel); + } + if ui + .button(format!( + "{} {}", + icons::ARROWS_LEFT_RIGHT.as_str(), + if conflict.show_metadata { + "Hide Metadata" + } else { + "Compare Metadata" + } + )) + .clicked() + { + action = Some(ConflictUiAction::ToggleMetadata); + } + if ui + .button(format!("{} Save As", icons::COPY.as_str())) + .clicked() + { + action = Some(ConflictUiAction::SaveAs); + } + let reload_supported = !matches!(conflict.intent, FileWriteIntent::Standalone { .. }); + if ui + .add_enabled( + reload_supported, + egui::Button::new(format!("{} Reload", icons::ARROW_CLOCKWISE.as_str())), + ) + .on_disabled_hover_text("This output is not an open editor document") + .clicked() + { + action = Some(ConflictUiAction::Reload); + } + }); + }); + if action.is_none() && response.should_close() { + action = Some(ConflictUiAction::Cancel); + } + + match action { + Some(ConflictUiAction::Cancel) => { + clear_file_conflict(world); + world.resource_mut::().set_status(format!( + "Save conflict cancelled; the local {} remains unsaved", + conflict.intent.display_name() + )); + } + Some(ConflictUiAction::ToggleMetadata) => { + if let Some(pending) = world + .resource_mut::() + .pending_conflict + .as_mut() + { + pending.show_metadata = !pending.show_metadata; + } + } + Some(ConflictUiAction::Reload) => resolve_conflict_reload(world, &conflict), + Some(ConflictUiAction::SaveAs) => resolve_conflict_save_as(world, &conflict), + None => {} + } +} + +fn conflict_reason_text(conflict: &PendingFileConflict) -> String { + match &conflict.reason { + FileConflictReason::ExternalChange => format!( + "The {} changed on disk after Blacksite loaded it.", + conflict.intent.display_name() + ), + FileConflictReason::ReadOnly => format!( + "The {} is read-only and cannot be replaced.", + conflict.intent.display_name() + ), + FileConflictReason::ProviderLocked { + provider, + owner, + detail, + } => { + let owner = owner.as_deref().unwrap_or("another owner"); + let detail = detail + .as_deref() + .map(|detail| format!(" {detail}")) + .unwrap_or_default(); + format!("{provider} reports this file is locked by {owner}.{detail}") + } + } +} + +fn metadata_comparison_ui(ui: &mut egui::Ui, conflict: &PendingFileConflict) { + egui::Grid::new("file_conflict_metadata") + .num_columns(3) + .striped(true) + .show(ui, |ui| { + ui.strong("Field"); + ui.strong("Loaded"); + ui.strong("Current"); + ui.end_row(); + metadata_row( + ui, + "Revision", + conflict.expected.revision.short_label(), + conflict.current.revision.short_label(), + ); + metadata_row( + ui, + "Bytes", + metadata_size(&conflict.expected.metadata), + metadata_size(&conflict.current.metadata), + ); + metadata_row( + ui, + "Modified", + metadata_modified(&conflict.expected.metadata), + metadata_modified(&conflict.current.metadata), + ); + metadata_row( + ui, + "Permissions", + metadata_permissions(&conflict.expected.metadata), + metadata_permissions(&conflict.current.metadata), + ); + }); +} + +fn metadata_row(ui: &mut egui::Ui, label: &str, loaded: String, current: String) { + ui.label(label); + ui.monospace(loaded); + ui.monospace(current); + ui.end_row(); +} + +fn metadata_size(metadata: &FileComparisonMetadata) -> String { + metadata + .byte_len + .map(|bytes| bytes.to_string()) + .unwrap_or_else(|| "-".into()) +} + +fn metadata_modified(metadata: &FileComparisonMetadata) -> String { + metadata + .modified_unix_millis + .map(|millis| format!("{}.{:03}", millis / 1_000, millis % 1_000)) + .unwrap_or_else(|| "-".into()) +} + +fn metadata_permissions(metadata: &FileComparisonMetadata) -> String { + if metadata.readonly { + "read-only".into() + } else { + "writable".into() + } +} + +fn resolve_conflict_reload(world: &mut World, conflict: &PendingFileConflict) { + let result = match &conflict.intent { + FileWriteIntent::Scene { tab_id, .. } => { + crate::scene_io::reload_scene_after_file_conflict(world, *tab_id, &conflict.path) + } + FileWriteIntent::Material | FileWriteIntent::MaterialInstance => { + crate::ui::reload_material_after_file_conflict(world, &conflict.path, &conflict.intent) + } + FileWriteIntent::ProjectSettings => { + crate::settings_ui::reload_project_settings_after_file_conflict(world, &conflict.path) + } + FileWriteIntent::PrefabSource { instance_root } + | FileWriteIntent::PrefabSourceHistory { instance_root } => { + crate::assets::prefab_overrides::reload_prefab_source_after_file_conflict( + world, + *instance_root, + ) + } + FileWriteIntent::Standalone { .. } => { + Err("This output is not an open editor document".into()) + } + }; + match result { + Ok(status) => { + clear_file_conflict(world); + if let Some(mut state) = world.get_resource_mut::() { + state.request_refresh(); + } + world.resource_mut::().set_status(status); + } + Err(error) => world + .resource_mut::() + .set_status(format!("Reload failed: {error}")), + } +} + +fn resolve_conflict_save_as(world: &mut World, conflict: &PendingFileConflict) { + let directory = conflict.path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = conflict_copy_file_name(&conflict.path); + let Some(destination) = rfd::FileDialog::new() + .set_directory(directory) + .set_file_name(file_name) + .save_file() + else { + world + .resource_mut::() + .set_status("Save conflict copy cancelled"); + return; + }; + let project_root = world + .get_resource::() + .map(|state| state.project_root.clone()) + .unwrap_or_else(|| PathBuf::from(".")); + if paths_equivalent(&project_root, &conflict.path, &destination) { + world.resource_mut::().set_status( + "Save As must use a different path; the conflicted source was not overwritten", + ); + return; + } + let material_catalog_path = if matches!( + conflict.intent, + FileWriteIntent::Material | FileWriteIntent::MaterialInstance + ) { + match crate::ui::validate_material_conflict_destination(world, &destination) { + Ok(path) => Some(path), + Err(error) => { + world.resource_mut::().set_status(error); + return; + } + } + } else { + None + }; + let expected = match FileSnapshot::capture(&destination) { + Ok(expected) => expected, + Err(error) => { + world.resource_mut::().set_status(error); + return; + } + }; + let disk_snapshot = match publish_authored_file( + world, + &destination, + &conflict.proposed_bytes, + &expected, + conflict.intent.clone(), + ) { + Ok(snapshot) => snapshot, + Err(error) => { + world + .resource_mut::() + .set_status(format!("Save As failed: {error}")); + return; + } + }; + + let result = match &conflict.intent { + FileWriteIntent::Scene { + tab_id, + entity_count, + } => crate::scene_io::adopt_scene_conflict_save_as( + world, + *tab_id, + destination, + disk_snapshot, + *entity_count, + ), + FileWriteIntent::Material | FileWriteIntent::MaterialInstance => { + crate::ui::adopt_material_conflict_save_as( + world, + &conflict.path, + material_catalog_path.expect("validated material path"), + disk_snapshot, + &conflict.intent, + ) + } + FileWriteIntent::ProjectSettings => { + crate::settings_ui::project_settings_conflict_copy_saved(world); + Ok(format!( + "Saved project settings copy to {}; active project manifest unchanged", + destination.display() + )) + } + FileWriteIntent::PrefabSource { .. } | FileWriteIntent::PrefabSourceHistory { .. } => { + Ok(format!( + "Saved proposed prefab source copy to {}; original link unchanged", + destination.display() + )) + } + FileWriteIntent::Standalone { description } => Ok(format!( + "Saved {description} copy to {}", + destination.display() + )), + }; + clear_file_conflict(world); + match result { + Ok(status) => world.resource_mut::().set_status(status), + Err(error) => world.resource_mut::().set_status(format!( + "Saved the copy, but editor state could not adopt it: {error}" + )), + } +} + +fn conflict_copy_file_name(path: &Path) -> String { + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("authored-file"); + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| format!("{stem}-copy.{extension}")) + .unwrap_or_else(|| format!("{stem}-copy")) +} + +fn paths_equivalent(project_root: &Path, first: &Path, second: &Path) -> bool { + let absolute = |path: &Path| { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + project_root.join(path) + }; + path.canonicalize() + .unwrap_or_else(|_| lexical_normalize(&path)) + }; + absolute(first) == absolute(second) +} + +fn clear_file_conflict(world: &mut World) { + if let Some(mut state) = world.get_resource_mut::() { + state.pending_conflict = None; + } +} + +fn queue_conflict(world: &mut World, conflict: PendingFileConflict) { + if matches!(conflict.intent, FileWriteIntent::Scene { .. }) { + let active_path = world.resource::().active_path.clone(); + if active_path.is_none() || active_path.as_deref() == Some(&conflict.path) { + world.resource_mut::().mark_dirty(); + } + } + if let Some(mut state) = world.get_resource_mut::() { + state.pending_conflict = Some(conflict); + } +} + +fn tick_collaboration_status(world: &mut World) { + finish_scan_if_ready(world); + + let project_root = PathBuf::from(&world.resource::().root); + let project_root = project_root.canonicalize().unwrap_or(project_root); + let tracked_paths = collect_tracked_paths(world, &project_root); + let should_start = { + let mut state = world.resource_mut::(); + if state.project_root != project_root { + state.project_root.clone_from(&project_root); + state.git_availability = GitAvailability::Unavailable; + state.git_files.clear(); + state.ownership.clear(); + state.observed_files.clear(); + state.request_refresh(); + } + if state.tracked_paths != tracked_paths { + state.tracked_paths.clone_from(&tracked_paths); + state.request_refresh(); + } + state.scan_job.is_none() + && state + .last_scan_started + .is_none_or(|started| started.elapsed() >= STATUS_REFRESH_INTERVAL) + }; + if !should_start { + return; + } + + let providers = world.resource::().providers.clone(); + let job = spawn_collaboration_scan(project_root, tracked_paths, providers); + let mut state = world.resource_mut::(); + state.scan_job = Some(job); + state.last_scan_started = Some(Instant::now()); +} + +fn finish_scan_if_ready(world: &mut World) { + let completed = world + .get_resource::() + .and_then(|state| state.scan_job.as_ref()) + .and_then(|job| job.result.lock().ok()?.take()); + if let Some(scan) = completed { + let job = world.resource_mut::().scan_job.take(); + drop(job); + let mut state = world.resource_mut::(); + state.git_availability = scan.git_availability; + state.git_files = scan.git_files; + state.ownership = scan.ownership; + state.provider_errors = scan.provider_errors; + state.observed_files = scan.observed_files; + return; + } + + let worker_finished = world + .get_resource::() + .and_then(|state| state.scan_job.as_ref()) + .and_then(|job| job.worker.as_ref()) + .is_some_and(JoinHandle::is_finished); + if worker_finished { + let job = world.resource_mut::().scan_job.take(); + drop(job); + let mut state = world.resource_mut::(); + state.git_availability = GitAvailability::Error( + "Collaboration status worker stopped before returning a result".into(), + ); + state.last_scan_started = Some(Instant::now()); + } +} + +fn collect_tracked_paths(world: &mut World, project_root: &Path) -> Vec { + let mut paths = HashSet::new(); + for tab in &world.resource::().tabs { + if let Some(path) = tab + .path + .as_deref() + .and_then(|path| project_relative_path(project_root, path)) + { + paths.insert(path); + } + } + if let Some(path) = world + .resource::() + .selected + .as_ref() + .and_then(|selection| selection.parent_path()) + .and_then(|path| project_relative_path(project_root, Path::new(path))) + { + paths.insert(path); + } + if let Some(path) = world + .get_resource::() + .and_then(|io| project_relative_path(project_root, Path::new(&io.path))) + { + paths.insert(path); + } + let prefab_sources: Vec = world + .query::<&PrefabInstance>() + .iter(world) + .map(|instance| instance.source_path.clone()) + .collect(); + for path in prefab_sources { + if let Some(path) = project_relative_path(project_root, Path::new(&path)) { + paths.insert(path); + } + } + let mut paths: Vec<_> = paths.into_iter().collect(); + paths.sort(); + paths +} + +fn spawn_collaboration_scan( + project_root: PathBuf, + tracked_paths: Vec, + providers: Vec>, +) -> CollaborationScanJob { + let result = Arc::new(Mutex::new(None)); + let worker_result = Arc::clone(&result); + let worker = std::thread::spawn(move || { + let mut scan = scan_git_status(&project_root, &tracked_paths); + scan.observed_files = tracked_paths + .iter() + .filter_map(|path| { + let metadata = fs::metadata(project_root.join(path)).ok()?; + Some(( + path.clone(), + ObservedFileMetadata { + readonly: metadata.permissions().readonly(), + }, + )) + }) + .collect(); + let (ownership, provider_errors) = + query_ownership_providers(&providers, &project_root, &tracked_paths); + scan.ownership = ownership; + scan.provider_errors = provider_errors; + if let Ok(mut slot) = worker_result.lock() { + *slot = Some(scan); + } + }); + CollaborationScanJob { + result, + worker: Some(worker), + } +} + +fn query_ownership_providers( + providers: &[Arc], + project_root: &Path, + tracked_paths: &[PathBuf], +) -> (HashMap>, Vec) { + let mut statuses: HashMap> = HashMap::new(); + let mut errors = Vec::new(); + for provider in providers { + let name = provider.name().to_string(); + match provider.query(project_root, tracked_paths) { + Ok(results) => { + for result in results { + let Some(path) = project_relative_path(project_root, &result.path) else { + errors.push(format!( + "{name} returned a path outside the project: {}", + result.path.display() + )); + continue; + }; + statuses.entry(path).or_default().push(ProviderFileState { + provider: name.clone(), + owner: result.owner, + locked_by_other: result.locked_by_other, + detail: result.detail, + }); + } + } + Err(error) => errors.push(format!("{name}: {error}")), + } + } + (statuses, errors) +} + +fn scan_git_status(project_root: &Path, tracked_paths: &[PathBuf]) -> CollaborationScan { + let mut scan = CollaborationScan::default(); + let root_output = match Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(project_root) + .args(["rev-parse", "--show-toplevel"]) + .output() + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + scan.git_availability = GitAvailability::Unavailable; + return scan; + } + Err(error) => { + scan.git_availability = + GitAvailability::Error(format!("Git status unavailable: {error}")); + return scan; + } + }; + if !root_output.status.success() { + scan.git_availability = GitAvailability::NotRepository; + return scan; + } + let git_root = PathBuf::from(String::from_utf8_lossy(&root_output.stdout).trim()); + let git_root = git_root.canonicalize().unwrap_or(git_root); + let project_prefix = project_root + .strip_prefix(&git_root) + .unwrap_or_else(|_| Path::new(".")); + let project_prefix = if project_prefix.as_os_str().is_empty() { + Path::new(".") + } else { + project_prefix + }; + let mut command = Command::new("git"); + command + .env("GIT_OPTIONAL_LOCKS", "0") + .arg("-C") + .arg(&git_root) + .args([ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignored=no", + "--", + ]); + if tracked_paths.is_empty() { + command.arg(project_prefix); + } else { + command.args(tracked_paths.iter().map(|path| project_prefix.join(path))); + } + let status_output = match command.output() { + Ok(output) if output.status.success() => output, + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + scan.git_availability = GitAvailability::Error(if stderr.is_empty() { + "Git status failed".into() + } else { + format!("Git status failed: {stderr}") + }); + return scan; + } + Err(error) => { + scan.git_availability = + GitAvailability::Error(format!("Git status unavailable: {error}")); + return scan; + } + }; + + scan.git_availability = GitAvailability::Available; + for (git_path, state) in parse_porcelain_v1_z(&status_output.stdout) { + let absolute = git_root.join(git_path); + let Some(relative) = project_relative_path(project_root, &absolute) else { + continue; + }; + scan.git_files + .entry(relative) + .and_modify(|current| { + if state.priority() > current.priority() { + *current = state; + } + }) + .or_insert(state); + } + scan +} + +fn parse_porcelain_v1_z(bytes: &[u8]) -> Vec<(PathBuf, GitFileState)> { + let mut records = bytes + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()); + let mut statuses = Vec::new(); + while let Some(record) = records.next() { + if record.len() < 4 || record[2] != b' ' { + continue; + } + let x = record[0]; + let y = record[1]; + let state = git_file_state(x, y); + statuses.push((path_from_git_bytes(&record[3..]), state)); + if matches!(x, b'R' | b'C') || matches!(y, b'R' | b'C') { + let _ = records.next(); + } + } + statuses +} + +fn git_file_state(x: u8, y: u8) -> GitFileState { + if x == b'?' && y == b'?' { + return GitFileState::Untracked; + } + if x == b'U' + || y == b'U' + || matches!( + (x, y), + (b'A', b'A') | (b'D', b'D') | (b'A', b'U') | (b'U', b'A') | (b'D', b'U') | (b'U', b'D') + ) + { + return GitFileState::Conflicted; + } + GitFileState::Modified +} + +#[cfg(unix)] +fn path_from_git_bytes(bytes: &[u8]) -> PathBuf { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + PathBuf::from(OsString::from_vec(bytes.to_vec())) +} + +#[cfg(not(unix))] +fn path_from_git_bytes(bytes: &[u8]) -> PathBuf { + PathBuf::from(String::from_utf8_lossy(bytes).into_owned()) +} + +fn project_relative_path(project_root: &Path, path: &Path) -> Option { + if project_root.as_os_str().is_empty() || path.as_os_str().is_empty() { + return None; + } + let root = lexical_normalize(project_root); + let absolute = if path.is_absolute() { + lexical_normalize(path) + } else { + lexical_normalize(&root.join(path)) + }; + let relative = absolute.strip_prefix(&root).ok()?; + if relative.as_os_str().is_empty() { + None + } else { + Some(relative.to_path_buf()) + } +} + +fn lexical_normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_directory(label: &str) -> PathBuf { + std::env::temp_dir().join(format!("blacksite-{label}-{}", uuid::Uuid::new_v4())) + } + + #[test] + fn guarded_write_detects_external_change_before_publish() { + let root = temp_directory("guarded-external-change"); + let path = root.join("scene.scn.ron"); + fs::create_dir_all(&root).unwrap(); + fs::write(&path, b"loaded").unwrap(); + let expected = FileSnapshot::capture(&path).unwrap(); + + let result = guarded_atomic_write_inner(&path, b"editor", &expected, || { + fs::write(&path, b"external").unwrap(); + }); + + assert!(matches!(result, Err(GuardedWriteError::Changed { .. }))); + assert_eq!(fs::read(&path).unwrap(), b"external"); + assert_eq!(fs::read_dir(&root).unwrap().count(), 1); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn guarded_create_does_not_replace_a_file_that_appears_during_save() { + let root = temp_directory("guarded-create-race"); + let path = root.join("new-material.ron"); + fs::create_dir_all(&root).unwrap(); + let expected = FileSnapshot::missing(); + + let result = guarded_atomic_write_inner(&path, b"editor", &expected, || { + fs::write(&path, b"external").unwrap(); + }); + + assert!(matches!(result, Err(GuardedWriteError::Changed { .. }))); + assert_eq!(fs::read(&path).unwrap(), b"external"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn identical_content_revision_ignores_metadata_drift() { + let root = temp_directory("guarded-content-identity"); + let path = root.join("material.ron"); + fs::create_dir_all(&root).unwrap(); + fs::write(&path, b"same").unwrap(); + let expected = FileSnapshot::capture(&path).unwrap(); + fs::write(&path, b"same").unwrap(); + + guarded_atomic_write(&path, b"replacement", &expected).unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"replacement"); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn read_only_target_is_blocked_without_replacement() { + let root = temp_directory("guarded-read-only"); + let path = root.join("material.ron"); + fs::create_dir_all(&root).unwrap(); + fs::write(&path, b"loaded").unwrap(); + let expected = FileSnapshot::capture(&path).unwrap(); + let original_permissions = fs::metadata(&path).unwrap().permissions(); + let mut read_only_permissions = original_permissions.clone(); + read_only_permissions.set_readonly(true); + fs::set_permissions(&path, read_only_permissions).unwrap(); + + let result = guarded_atomic_write(&path, b"editor", &expected); + + assert!(matches!(result, Err(GuardedWriteError::ReadOnly { .. }))); + assert_eq!(fs::read(&path).unwrap(), b"loaded"); + fs::set_permissions(&path, original_permissions).unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn provider_lock_queues_recovery_without_writing() { + let root = temp_directory("provider-lock"); + let path = root.join("assets/levels/locked.scn.ron"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"loaded").unwrap(); + let expected = FileSnapshot::capture(&path).unwrap(); + let mut state = CollaborationState { + project_root: root.clone(), + ..Default::default() + }; + state.ownership.insert( + PathBuf::from("assets/levels/locked.scn.ron"), + vec![ProviderFileState { + provider: "test-provider".into(), + owner: Some("teammate".into()), + locked_by_other: true, + detail: Some("exclusive edit".into()), + }], + ); + let mut world = World::new(); + world.insert_resource(state); + + let result = publish_authored_file( + &mut world, + &path, + b"editor", + &expected, + FileWriteIntent::Standalone { + description: "test document".into(), + }, + ); + + assert!(result.unwrap_err().contains("locked by another owner")); + assert_eq!(fs::read(&path).unwrap(), b"loaded"); + assert!(matches!( + world + .resource::() + .pending_conflict + .as_ref() + .map(|conflict| &conflict.reason), + Some(FileConflictReason::ProviderLocked { .. }) + )); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn porcelain_parser_handles_spaces_conflicts_and_rename_records() { + let bytes = b" M assets/levels/main scene.scn.ron\0?? assets/new.ron\0UU assets/conflict.ron\0R assets/new-name.ron\0assets/old-name.ron\0"; + let parsed = parse_porcelain_v1_z(bytes); + + assert_eq!( + parsed, + vec![ + ( + PathBuf::from("assets/levels/main scene.scn.ron"), + GitFileState::Modified + ), + (PathBuf::from("assets/new.ron"), GitFileState::Untracked), + ( + PathBuf::from("assets/conflict.ron"), + GitFileState::Conflicted + ), + (PathBuf::from("assets/new-name.ron"), GitFileState::Modified), + ] + ); + } + + #[test] + fn git_scan_reports_untracked_files_without_creating_an_index() { + if Command::new("git").arg("--version").output().is_err() { + return; + } + let root = temp_directory("git-observation"); + fs::create_dir_all(root.join("assets")).unwrap(); + let initialized = Command::new("git") + .arg("init") + .arg("--quiet") + .arg(&root) + .status() + .unwrap(); + assert!(initialized.success()); + fs::write(root.join("assets/untracked.ron"), b"()").unwrap(); + assert!(!root.join(".git/index").exists()); + + fs::write(root.join("assets/unrelated.ron"), b"()").unwrap(); + let scan = scan_git_status(&root, &[PathBuf::from("assets/untracked.ron")]); + + assert_eq!(scan.git_availability, GitAvailability::Available); + assert_eq!( + scan.git_files.get(Path::new("assets/untracked.ron")), + Some(&GitFileState::Untracked) + ); + assert!(!scan + .git_files + .contains_key(Path::new("assets/unrelated.ron"))); + assert!(!root.join(".git/index").exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn non_repository_scan_is_a_quiet_normal_state() { + if Command::new("git").arg("--version").output().is_err() { + return; + } + let root = temp_directory("git-absent-repository"); + fs::create_dir_all(&root).unwrap(); + + let scan = scan_git_status(&root, &[]); + + assert_eq!(scan.git_availability, GitAvailability::NotRepository); + assert!(scan.git_files.is_empty()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn collaboration_tick_publishes_status_for_a_tracked_scene() { + if Command::new("git").arg("--version").output().is_err() { + return; + } + let root = temp_directory("git-worker-integration"); + let scene_path = root.join("assets/levels/tracked.scn.ron"); + fs::create_dir_all(scene_path.parent().unwrap()).unwrap(); + fs::write(&scene_path, b"()").unwrap(); + let initialized = Command::new("git") + .arg("init") + .arg("--quiet") + .arg(&root) + .status() + .unwrap(); + assert!(initialized.success()); + + let workspace = ProjectWorkspace { + root: root.to_string_lossy().into_owned(), + ..Default::default() + }; + let mut scene_io = SceneIo::default(); + scene_io.tabs[0].path = Some(scene_path.clone()); + let editor_assets = EditorAssets { + folders: Vec::new(), + assets: Vec::new(), + current_folder: "assets".into(), + selected: None, + dragging: None, + status: String::new(), + }; + let mut world = World::new(); + world.insert_resource(workspace); + world.insert_resource(scene_io); + world.insert_resource(editor_assets); + world.insert_resource(FileOwnershipProviders::default()); + world.insert_resource(CollaborationState::default()); + + tick_collaboration_status(&mut world); + for _ in 0..250 { + finish_scan_if_ready(&mut world); + if world.resource::().scan_job.is_none() { + break; + } + std::thread::sleep(Duration::from_millis(2)); + } + + let status = world + .resource::() + .file_status(&scene_path); + assert_eq!(status.git, Some(GitFileState::Untracked)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn empty_provider_registry_is_a_quiet_noop() { + let (statuses, errors) = query_ownership_providers(&[], Path::new("/project"), &[]); + assert!(statuses.is_empty()); + assert!(errors.is_empty()); + } + + #[test] + fn failed_status_worker_is_retired_and_scheduled_for_retry() { + let result = Arc::new(Mutex::new(None)); + let worker = std::thread::spawn(|| {}); + while !worker.is_finished() { + std::thread::yield_now(); + } + let state = CollaborationState { + scan_job: Some(CollaborationScanJob { + result, + worker: Some(worker), + }), + last_scan_started: Some(Instant::now()), + ..Default::default() + }; + let mut world = World::new(); + world.insert_resource(state); + + finish_scan_if_ready(&mut world); + + let state = world.resource::(); + assert!(state.scan_job.is_none()); + assert!(state.last_scan_started.is_some()); + assert!(matches!(state.git_availability, GitAvailability::Error(_))); + } + + #[test] + fn status_indicator_prioritizes_blocking_state() { + let status = FileCollaborationStatus { + git: Some(GitFileState::Modified), + readonly: true, + ..Default::default() + }; + assert_eq!( + status.indicator(), + Some(FileStatusIndicator { + label: "READ ONLY", + tone: FileStatusTone::Error, + }) + ); + } + + #[test] + fn conflict_modal_renders_without_mutating_the_pending_write() { + let path = PathBuf::from("assets/levels/main.scn.ron"); + let state = CollaborationState { + pending_conflict: Some(PendingFileConflict { + path: path.clone(), + expected: FileSnapshot::missing(), + current: FileSnapshot { + revision: FileRevision::Present([7; 32]), + metadata: FileComparisonMetadata { + byte_len: Some(42), + modified_unix_millis: Some(1_000), + readonly: false, + }, + }, + reason: FileConflictReason::ExternalChange, + intent: FileWriteIntent::Standalone { + description: "test scene".into(), + }, + proposed_bytes: b"editor".to_vec(), + show_metadata: true, + }), + ..Default::default() + }; + let mut world = World::new(); + world.insert_resource(state); + world.insert_resource(SceneIo::default()); + let context = egui::Context::default(); + let mut fonts = egui::FontDefinitions::default(); + egui_phosphor_icons::add_fonts(&mut fonts); + context.set_fonts(fonts); + context.begin_pass(egui::RawInput::default()); + + file_conflict_modal(&mut world, &context); + let output = context.end_pass(); + + assert!(!output.shapes.is_empty()); + assert!(world + .resource::() + .pending_conflict + .is_some()); + } + + #[test] + fn project_relative_paths_reject_escape_and_accept_absolute_project_files() { + let root = Path::new("/project"); + assert_eq!( + project_relative_path(root, Path::new("assets/a.ron")), + Some(PathBuf::from("assets/a.ron")) + ); + assert_eq!( + project_relative_path(root, Path::new("/project/assets/a.ron")), + Some(PathBuf::from("assets/a.ron")) + ); + assert_eq!( + project_relative_path(root, Path::new("../outside.ron")), + None + ); + } +} diff --git a/crates/editor/src/project/mod.rs b/crates/editor/src/project/mod.rs index 373c2e4..2faab20 100644 --- a/crates/editor/src/project/mod.rs +++ b/crates/editor/src/project/mod.rs @@ -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; diff --git a/crates/editor/src/project/settings_ui.rs b/crates/editor/src/project/settings_ui.rs index 8977b55..b0beea6 100644 --- a/crates/editor/src/project/settings_ui.rs +++ b/crates/editor/src/project/settings_ui.rs @@ -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, + disk_snapshot: Option, + save_error: Option, } 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::().clone()); + let io = world.resource::(); + 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::().path.clone(); let mut io_dirty = world.resource::().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::().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, + save_error: &mut Option, ) { let caps = world .get_resource::() .cloned() .unwrap_or_default(); + ui.horizontal_wrapped(|ui| { + ui.small(egui::RichText::new(io_path).monospace()); + if let Some(state) = world.get_resource::() { + 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::().clone(); - let mut io = world.resource_mut::(); - 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::().set_status(error); + return; + }; + match persist_project_settings(world, &settings, &expected) { + Ok((snapshot, source)) => { + let mut io = world.resource_mut::(); + io.dirty = false; + io.loaded_source = Some(source); + *disk_snapshot = Some(snapshot); + *save_error = None; + *io_dirty = false; + world + .resource_mut::() + .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::() + .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::().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::(); + 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::() + .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::().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 { + let active_path = world.resource::().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::(); + io.dirty = false; + io.loaded_source = Some(source); + } + { + let mut panel = world.resource_mut::(); + 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::().save_error = None; +} + fn commit_project_settings(world: &mut World, draft: &ProjectSettings) { let previous = world.resource::().rendering.gi_mode; *world.resource_mut::() = 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(); + } +} diff --git a/crates/editor/src/scene/recovery.rs b/crates/editor/src/scene/recovery.rs index 01d0c7f..1cd8558 100644 --- a/crates/editor/src/scene/recovery.rs +++ b/crates/editor/src/scene/recovery.rs @@ -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( path: &Path, bytes: &[u8], - before_rename: impl FnOnce() -> Result<(), String>, -) -> Result<(), String> { + before_rename: impl FnOnce() -> Result<(), E>, +) -> Result<(), E> +where + E: From, +{ 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(()) diff --git a/crates/editor/src/scene/scene_io.rs b/crates/editor/src/scene/scene_io.rs index 0d8c454..c4dbb68 100644 --- a/crates/editor/src/scene/scene_io.rs +++ b/crates/editor/src/scene/scene_io.rs @@ -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, + disk_snapshot: Option, } 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::().active_path = Some(path.clone()); - world.resource_mut::().mark_clean(); + Ok(disk_snapshot) => { + { + let mut io = world.resource_mut::(); + 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::().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::().allocate_tab_id(); { let mut io = world.resource_mut::(); @@ -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::().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::().refresh(); format!( @@ -775,6 +798,7 @@ fn save_prefab_entities( world: &mut World, path: &Path, entities: Vec, + write_context: SceneWriteContext, ) -> Result { 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::().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::().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::().active_path = Some(path.clone()); world.resource_mut::().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 { +fn save_level( + world: &mut World, + path: &Path, + write_context: SceneWriteContext, +) -> Result { 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) -> Result { +fn save_entities( + world: &mut World, + path: &Path, + entities: Vec, + write_context: SceneWriteContext, +) -> Result { 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, + write_context: SceneWriteContext, ) -> Result { 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::(); + 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::(); + 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 { + let io = world.resource::(); + 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) -> Result { 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::(), active_path); world.resource_mut::().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) { @@ -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 { 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 { + let active_matches = { + let io = world.resource::(); + 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::(); + 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::().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 { + let active_matches = { + let io = world.resource::(); + 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::(); + 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::().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::(); + 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::().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::(); - 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::(); - write_recovery_copy(&snapshot, &destination).unwrap(); + write_recovery_copy(&mut world, &snapshot, &destination, &expected).unwrap(); assert_eq!( std::fs::read(&destination).unwrap(), diff --git a/crates/editor/src/ui/asset_browser/panel.rs b/crates/editor/src/ui/asset_browser/panel.rs index 0d7fabb..735155e 100644 --- a/crates/editor/src/ui/asset_browser/panel.rs +++ b/crates/editor/src/ui/asset_browser/panel.rs @@ -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::(), + 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::(&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::() .material_draft .as_mut() { current.error = None; + current.disk_snapshot = disk_snapshot; } world.resource_mut::().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::().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::(&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::() .material_instance_draft .as_mut() { current.error = None; + current.disk_snapshot = disk_snapshot; } world.resource_mut::().refresh(); invalidate_on_catalog_refresh(world); @@ -4380,10 +4430,181 @@ fn dedup_strings(values: &mut Vec) { values.retain(|value| seen.insert(value.clone())); } +pub(crate) fn validate_material_conflict_destination( + world: &World, + destination: &Path, +) -> Result { + let root = PathBuf::from(&world.resource::().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 { + let path = path.to_string_lossy(); + { + let mut state = world.resource_mut::(); + 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::().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 { + let original_path = original_path.to_string_lossy(); + { + let mut state = world.resource_mut::(); + 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::(); + 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::() + .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 { diff --git a/crates/editor/src/ui/asset_browser/state.rs b/crates/editor/src/ui/asset_browser/state.rs index 2c7e197..a3c98d4 100644 --- a/crates/editor/src/ui/asset_browser/state.rs +++ b/crates/editor/src/ui/asset_browser/state.rs @@ -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, } @@ -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, } diff --git a/crates/editor/src/ui/mod.rs b/crates/editor/src/ui/mod.rs index 422fb3d..c50dd18 100644 --- a/crates/editor/src/ui/mod.rs +++ b/crates/editor/src/ui/mod.rs @@ -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::().shortcuts_open = true; } diff --git a/crates/editor/src/ui/status_bar.rs b/crates/editor/src/ui/status_bar.rs index ae904db..07eb8cb 100644 --- a/crates/editor/src/ui/status_bar.rs +++ b/crates/editor/src/ui/status_bar.rs @@ -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::(), + world.resource::().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 => { diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index b97293a..b5862fd 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -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) { 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#"( diff --git a/crates/settings/src/plugin.rs b/crates/settings/src/plugin.rs index 7fc5db5..9b7a9bb 100644 --- a/crates/settings/src/plugin.rs +++ b/crates/settings/src/plugin.rs @@ -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, } 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) { - let settings = load_project_settings_from_path(&io.path); +fn load_settings(mut commands: Commands, mut io: ResMut) { + 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 *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(()) } diff --git a/docs/README.md b/docs/README.md index 0ad70b7..20189a4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/adr/0037-collaborative-authored-file-safety.md b/docs/adr/0037-collaborative-authored-file-safety.md new file mode 100644 index 0000000..b0c7b24 --- /dev/null +++ b/docs/adr/0037-collaborative-authored-file-safety.md @@ -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. diff --git a/docs/editor/README.md b/docs/editor/README.md index 03d2b84..634d4bd 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -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. diff --git a/docs/editor/architecture.md b/docs/editor/architecture.md index b21e625..4c51070 100644 --- a/docs/editor/architecture.md +++ b/docs/editor/architecture.md @@ -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 | diff --git a/docs/editor/collaborative-file-safety.md b/docs/editor/collaborative-file-safety.md new file mode 100644 index 0000000..b8ec3cc --- /dev/null +++ b/docs/editor/collaborative-file-safety.md @@ -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). diff --git a/docs/editor/roadmap.md b/docs/editor/roadmap.md index 732dcbb..ca30086 100644 --- a/docs/editor/roadmap.md +++ b/docs/editor/roadmap.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)