//! 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::native_dialog::NativeDialogBroker; 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 conflict = conflict.clone(); let directory = conflict .path .parent() .unwrap_or_else(|| Path::new(".")) .to_path_buf(); let file_name = conflict_copy_file_name(&conflict.path); let request = world.resource::().request( move || { rfd::FileDialog::new() .set_directory(directory) .set_file_name(file_name) .save_file() }, move |world, destination| finish_conflict_save_as(world, conflict, destination), ); world.resource_mut::().set_status(match request { Ok(()) => "Choose a destination for the conflict copy".into(), Err(error) => format!("Save As unavailable: {error}"), }); } fn finish_conflict_save_as( world: &mut World, conflict: PendingFileConflict, destination: Option, ) { let Some(destination) = destination 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 ); } }