Blacksite/crates/editor/src/project/collaboration.rs

1754 lines
57 KiB
Rust

//! 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<u64>,
pub modified_unix_millis: Option<u128>,
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<Self, String> {
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<u128> {
time.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_millis())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuardedWriteError {
Changed {
expected: Box<FileSnapshot>,
current: Box<FileSnapshot>,
},
ReadOnly {
expected: Box<FileSnapshot>,
current: Box<FileSnapshot>,
},
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<String> for GuardedWriteError {
fn from(error: String) -> Self {
Self::Io(error)
}
}
pub fn guarded_atomic_write(
path: &Path,
bytes: &[u8],
expected: &FileSnapshot,
) -> Result<FileSnapshot, GuardedWriteError> {
guarded_atomic_write_inner(path, bytes, expected, || {})
}
fn guarded_atomic_write_inner(
path: &Path,
bytes: &[u8],
expected: &FileSnapshot,
before_final_check: impl FnOnce(),
) -> Result<FileSnapshot, GuardedWriteError> {
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<FileSnapshot, GuardedWriteError> {
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<String>,
pub locked_by_other: bool,
pub detail: Option<String>,
}
/// 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<Vec<FileOwnership>, String>;
}
#[derive(Resource, Default)]
pub struct FileOwnershipProviders {
providers: Vec<Arc<dyn FileOwnershipProvider>>,
}
impl FileOwnershipProviders {
pub fn register(&mut self, provider: Arc<dyn FileOwnershipProvider>) {
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::<FileOwnershipProviders>();
app.world_mut()
.resource_mut::<FileOwnershipProviders>()
.register_typed(provider);
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProviderFileState {
provider: String,
owner: Option<String>,
locked_by_other: bool,
detail: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ObservedFileMetadata {
readonly: bool,
}
#[derive(Debug, Clone, Default)]
struct CollaborationScan {
git_availability: GitAvailability,
git_files: HashMap<PathBuf, GitFileState>,
ownership: HashMap<PathBuf, Vec<ProviderFileState>>,
provider_errors: Vec<String>,
observed_files: HashMap<PathBuf, ObservedFileMetadata>,
}
struct CollaborationScanJob {
result: Arc<Mutex<Option<CollaborationScan>>>,
worker: Option<JoinHandle<()>>,
}
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<String>,
detail: Option<String>,
},
}
#[derive(Debug, Clone)]
struct PendingFileConflict {
path: PathBuf,
expected: FileSnapshot,
current: FileSnapshot,
reason: FileConflictReason,
intent: FileWriteIntent,
proposed_bytes: Vec<u8>,
show_metadata: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FileCollaborationStatus {
pub git: Option<GitFileState>,
pub readonly: bool,
pub owner: Option<String>,
pub locked_by_other: bool,
pub provider: Option<String>,
pub ownership_detail: Option<String>,
pub diagnostics: Vec<String>,
}
#[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<FileStatusIndicator> {
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<egui::Response> {
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<PathBuf>,
git_availability: GitAvailability,
git_files: HashMap<PathBuf, GitFileState>,
ownership: HashMap<PathBuf, Vec<ProviderFileState>>,
provider_errors: Vec<String>,
observed_files: HashMap<PathBuf, ObservedFileMetadata>,
scan_job: Option<CollaborationScanJob>,
last_scan_started: Option<Instant>,
pending_conflict: Option<PendingFileConflict>,
}
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::<FileOwnershipProviders>()
.init_resource::<CollaborationState>()
.add_systems(Update, tick_collaboration_status);
}
}
pub(crate) fn publish_authored_file(
world: &mut World,
path: &Path,
bytes: &[u8],
expected: &FileSnapshot,
intent: FileWriteIntent,
) -> Result<FileSnapshot, String> {
if let Some((provider, owner, detail)) = world
.get_resource::<CollaborationState>()
.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::<CollaborationState>() {
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::<CollaborationState>()
.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::<SceneIo>().set_status(format!(
"Save conflict cancelled; the local {} remains unsaved",
conflict.intent.display_name()
));
}
Some(ConflictUiAction::ToggleMetadata) => {
if let Some(pending) = world
.resource_mut::<CollaborationState>()
.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::<CollaborationState>() {
state.request_refresh();
}
world.resource_mut::<SceneIo>().set_status(status);
}
Err(error) => world
.resource_mut::<SceneIo>()
.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::<NativeDialogBroker>().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::<SceneIo>().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<PathBuf>,
) {
let Some(destination) = destination else {
world
.resource_mut::<SceneIo>()
.set_status("Save conflict copy cancelled");
return;
};
let project_root = world
.get_resource::<CollaborationState>()
.map(|state| state.project_root.clone())
.unwrap_or_else(|| PathBuf::from("."));
if paths_equivalent(&project_root, &conflict.path, &destination) {
world.resource_mut::<SceneIo>().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::<SceneIo>().set_status(error);
return;
}
}
} else {
None
};
let expected = match FileSnapshot::capture(&destination) {
Ok(expected) => expected,
Err(error) => {
world.resource_mut::<SceneIo>().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::<SceneIo>()
.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::<SceneIo>().set_status(status),
Err(error) => world.resource_mut::<SceneIo>().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::<CollaborationState>() {
state.pending_conflict = None;
}
}
fn queue_conflict(world: &mut World, conflict: PendingFileConflict) {
if matches!(conflict.intent, FileWriteIntent::Scene { .. }) {
let active_path = world.resource::<SceneIo>().active_path.clone();
if active_path.is_none() || active_path.as_deref() == Some(&conflict.path) {
world.resource_mut::<SceneIo>().mark_dirty();
}
}
if let Some(mut state) = world.get_resource_mut::<CollaborationState>() {
state.pending_conflict = Some(conflict);
}
}
fn tick_collaboration_status(world: &mut World) {
finish_scan_if_ready(world);
let project_root = PathBuf::from(&world.resource::<ProjectWorkspace>().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::<CollaborationState>();
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::<FileOwnershipProviders>().providers.clone();
let job = spawn_collaboration_scan(project_root, tracked_paths, providers);
let mut state = world.resource_mut::<CollaborationState>();
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::<CollaborationState>()
.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::<CollaborationState>().scan_job.take();
drop(job);
let mut state = world.resource_mut::<CollaborationState>();
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::<CollaborationState>()
.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::<CollaborationState>().scan_job.take();
drop(job);
let mut state = world.resource_mut::<CollaborationState>();
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<PathBuf> {
let mut paths = HashSet::new();
for tab in &world.resource::<SceneIo>().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::<EditorAssets>()
.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::<settings::ProjectSettingsIo>()
.and_then(|io| project_relative_path(project_root, Path::new(&io.path)))
{
paths.insert(path);
}
let prefab_sources: Vec<String> = 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<PathBuf>,
providers: Vec<Arc<dyn FileOwnershipProvider>>,
) -> 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<dyn FileOwnershipProvider>],
project_root: &Path,
tracked_paths: &[PathBuf],
) -> (HashMap<PathBuf, Vec<ProviderFileState>>, Vec<String>) {
let mut statuses: HashMap<PathBuf, Vec<ProviderFileState>> = 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<PathBuf> {
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::<CollaborationState>()
.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::<CollaborationState>().scan_job.is_none() {
break;
}
std::thread::sleep(Duration::from_millis(2));
}
let status = world
.resource::<CollaborationState>()
.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::<CollaborationState>();
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::<CollaborationState>()
.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
);
}
}