1754 lines
57 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|