mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
- Implemented a new `suppress_path` method to prevent re-processing of file-watcher events triggered by the ChangeProcessor, enhancing event handling during renames and writes. - Updated various sections of the `change_processor.rs` file to call `suppress_path` for relevant paths during rename operations, ensuring accurate metadata synchronization and preventing unnecessary processing. - Enhanced the logic for updating instance metadata to maintain consistency after filesystem path changes, improving the robustness of the change processing mechanism.
1194 lines
57 KiB
Rust
1194 lines
57 KiB
Rust
use crossbeam_channel::{select, Receiver, RecvError, Sender};
|
|
use jod_thread::JoinHandle;
|
|
use memofs::{IoResultExt, Vfs, VfsEvent};
|
|
use rbx_dom_weak::types::{Ref, Variant};
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, Instant};
|
|
use std::{
|
|
fs,
|
|
sync::{Arc, Mutex},
|
|
};
|
|
|
|
use crate::{
|
|
message_queue::MessageQueue,
|
|
path_encoding::encode_path_name,
|
|
snapshot::{
|
|
apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree,
|
|
},
|
|
snapshot_middleware::{snapshot_from_vfs, snapshot_project_node},
|
|
};
|
|
|
|
/// Processes file change events, updates the DOM, and sends those updates
|
|
/// through a channel for other stuff to consume.
|
|
///
|
|
/// Owns the connection between Rojo's VFS and its DOM by holding onto another
|
|
/// thread that processes messages.
|
|
///
|
|
/// Consumers of ChangeProcessor, like ServeSession, are intended to communicate
|
|
/// with this object via channels.
|
|
///
|
|
/// ChangeProcessor expects to be the only writer to the RojoTree and Vfs
|
|
/// objects passed to it.
|
|
pub struct ChangeProcessor {
|
|
/// Controls the runtime of the processor thread. When signaled, the job
|
|
/// thread will finish its current work and terminate.
|
|
///
|
|
/// This channel should be signaled before dropping ChangeProcessor or we'll
|
|
/// hang forever waiting for the message processing loop to terminate.
|
|
shutdown_sender: Sender<()>,
|
|
|
|
/// A handle to the message processing thread. When dropped, we'll block
|
|
/// until it's done.
|
|
///
|
|
/// Allowed to be unused because dropping this value has side effects.
|
|
#[allow(unused)]
|
|
job_thread: JoinHandle<Result<(), RecvError>>,
|
|
}
|
|
|
|
impl ChangeProcessor {
|
|
/// Spin up the ChangeProcessor, connecting it to the given tree, VFS, and
|
|
/// outbound message queue.
|
|
pub fn start(
|
|
tree: Arc<Mutex<RojoTree>>,
|
|
vfs: Arc<Vfs>,
|
|
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
|
|
tree_mutation_receiver: Receiver<PatchSet>,
|
|
suppressed_paths: Arc<Mutex<std::collections::HashMap<PathBuf, usize>>>,
|
|
) -> Self {
|
|
let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1);
|
|
let vfs_receiver = vfs.event_receiver();
|
|
let task = JobThreadContext {
|
|
tree,
|
|
vfs,
|
|
message_queue,
|
|
pending_recovery: Mutex::new(Vec::new()),
|
|
suppressed_paths,
|
|
};
|
|
|
|
let job_thread = jod_thread::Builder::new()
|
|
.name("ChangeProcessor thread".to_owned())
|
|
.spawn(move || {
|
|
log::trace!("ChangeProcessor thread started");
|
|
|
|
loop {
|
|
select! {
|
|
recv(vfs_receiver) -> event => {
|
|
task.handle_vfs_event(event?);
|
|
task.process_pending_recoveries();
|
|
},
|
|
recv(tree_mutation_receiver) -> patch_set => {
|
|
task.handle_tree_event(patch_set?);
|
|
},
|
|
recv(shutdown_receiver) -> _ => {
|
|
log::trace!("ChangeProcessor shutdown signal received...");
|
|
return Ok(());
|
|
},
|
|
default(Duration::from_millis(500)) => {
|
|
// Periodic sweep even when no events arrive —
|
|
// catches files recreated after their Remove was processed.
|
|
task.process_pending_recoveries();
|
|
},
|
|
}
|
|
}
|
|
})
|
|
.expect("Could not start ChangeProcessor thread");
|
|
|
|
Self {
|
|
shutdown_sender,
|
|
job_thread,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for ChangeProcessor {
|
|
fn drop(&mut self) {
|
|
// Signal the job thread to start spinning down. Without this we'll hang
|
|
// forever waiting for the thread to finish its infinite loop.
|
|
let _ = self.shutdown_sender.send(());
|
|
|
|
// After this function ends, the job thread will be joined. It might
|
|
// block for a small period of time while it processes its last work.
|
|
}
|
|
}
|
|
|
|
/// Contains all of the state needed to synchronize the DOM and VFS.
|
|
struct JobThreadContext {
|
|
/// A handle to the DOM we're managing.
|
|
tree: Arc<Mutex<RojoTree>>,
|
|
|
|
/// A handle to the VFS we're managing.
|
|
vfs: Arc<Vfs>,
|
|
|
|
/// Whenever changes are applied to the DOM, we should push those changes
|
|
/// into this message queue to inform any connected clients.
|
|
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
|
|
|
|
/// Paths recently removed from the tree that should be re-checked after a
|
|
/// delay. On Windows, rapid delete+recreate (e.g., editor undo) can cause
|
|
/// the Remove event to arrive but the Create event to be lost. We record
|
|
/// removed paths here and periodically verify they are still gone.
|
|
pending_recovery: Mutex<Vec<(PathBuf, Instant)>>,
|
|
|
|
/// Paths recently written by the API's syncback. Events for these paths
|
|
/// are suppressed to avoid redundant re-snapshots. Values are counts.
|
|
suppressed_paths: Arc<Mutex<std::collections::HashMap<PathBuf, usize>>>,
|
|
}
|
|
|
|
impl JobThreadContext {
|
|
/// Find the init file inside a directory-format script folder.
|
|
/// Returns the path to the first `init.*.luau` or `init.*.lua` found.
|
|
fn find_init_file(dir: &Path) -> Option<PathBuf> {
|
|
// Check known init file names in priority order
|
|
let candidates = [
|
|
"init.luau",
|
|
"init.server.luau",
|
|
"init.client.luau",
|
|
"init.local.luau",
|
|
"init.plugin.luau",
|
|
"init.legacy.luau",
|
|
"init.lua",
|
|
"init.server.lua",
|
|
"init.client.lua",
|
|
"init.local.lua",
|
|
];
|
|
for name in &candidates {
|
|
let candidate = dir.join(name);
|
|
if candidate.exists() {
|
|
return Some(candidate);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Suppress a single VFS event for the given path. Prevents the
|
|
/// ChangeProcessor from re-processing a file-watcher event that we
|
|
/// triggered ourselves (renames, writes).
|
|
fn suppress_path(&self, path: &Path) {
|
|
let mut suppressed = self.suppressed_paths.lock().unwrap();
|
|
let key = if let Ok(canonical) = std::fs::canonicalize(path) {
|
|
canonical
|
|
} else if let Some(parent) = path.parent() {
|
|
if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
|
|
if let Some(file_name) = path.file_name() {
|
|
canonical_parent.join(file_name)
|
|
} else {
|
|
path.to_path_buf()
|
|
}
|
|
} else {
|
|
path.to_path_buf()
|
|
}
|
|
} else {
|
|
path.to_path_buf()
|
|
};
|
|
*suppressed.entry(key).or_insert(0) += 1;
|
|
}
|
|
|
|
/// Computes and applies patches to the DOM for a given file path.
|
|
///
|
|
/// This function finds the nearest ancestor to the given path that has associated instances
|
|
/// in the tree.
|
|
/// It then computes and applies changes for each affected instance ID and
|
|
/// returns a vector of applied patch sets.
|
|
fn apply_patches(&self, path: PathBuf) -> Vec<AppliedPatchSet> {
|
|
let mut tree = self.tree.lock().unwrap();
|
|
let mut applied_patches = Vec::new();
|
|
|
|
// Find the nearest ancestor to this path that has
|
|
// associated instances in the tree. This helps make sure
|
|
// that we handle additions correctly, especially if we
|
|
// receive events for descendants of a large tree being
|
|
// created all at once.
|
|
let mut current_path = path.as_path();
|
|
let affected_ids = loop {
|
|
let ids = tree.get_ids_at_path(current_path);
|
|
|
|
log::info!(
|
|
"apply_patches: path {} affects IDs {:?}",
|
|
current_path.display(),
|
|
ids
|
|
);
|
|
|
|
if !ids.is_empty() {
|
|
break ids.to_vec();
|
|
}
|
|
|
|
log::info!(
|
|
"apply_patches: no IDs at {}, trying parent...",
|
|
current_path.display()
|
|
);
|
|
match current_path.parent() {
|
|
Some(parent) => current_path = parent,
|
|
None => break Vec::new(),
|
|
}
|
|
};
|
|
|
|
if affected_ids.is_empty() {
|
|
log::info!(
|
|
"apply_patches: no affected instances found for path {}",
|
|
path.display()
|
|
);
|
|
}
|
|
|
|
for id in affected_ids {
|
|
if let Some(result) = compute_and_apply_changes(&mut tree, &self.vfs, id) {
|
|
// If an instance was removed, schedule a recovery check
|
|
// in case the path is recreated momentarily.
|
|
if let Some(removed_path) = result.removed_path {
|
|
let mut pending = self.pending_recovery.lock().unwrap();
|
|
pending.push((removed_path, Instant::now()));
|
|
}
|
|
|
|
if !result.applied.is_empty() {
|
|
applied_patches.push(result.applied);
|
|
}
|
|
}
|
|
}
|
|
|
|
applied_patches
|
|
}
|
|
|
|
fn handle_vfs_event(&self, event: VfsEvent) {
|
|
// Log EVERY VFS event at INFO level for diagnostics.
|
|
// This is intentionally verbose — it is critical for debugging
|
|
// file watcher desync issues (e.g., rapid delete+recreate).
|
|
match &event {
|
|
VfsEvent::Create(path) => log::info!("VFS event: CREATE {}", path.display()),
|
|
VfsEvent::Write(path) => log::info!("VFS event: WRITE {}", path.display()),
|
|
VfsEvent::Remove(path) => log::info!("VFS event: REMOVE {}", path.display()),
|
|
_ => log::info!("VFS event: OTHER {:?}", event),
|
|
}
|
|
|
|
// Check if this event should be suppressed (one-shot, from API syncback).
|
|
// Try both the raw event path and its canonical form since suppress_path
|
|
// stores whichever form it could resolve at insertion time.
|
|
let event_path = match &event {
|
|
VfsEvent::Create(p) | VfsEvent::Write(p) | VfsEvent::Remove(p) => Some(p.clone()),
|
|
_ => None,
|
|
};
|
|
if let Some(ref path) = event_path {
|
|
let canonical = std::fs::canonicalize(path).ok();
|
|
let mut suppressed = self.suppressed_paths.lock().unwrap();
|
|
// Determine which key matches: try canonical first (most likely
|
|
// to match what suppress_path stored), then fall back to raw.
|
|
let matched_key = canonical
|
|
.as_ref()
|
|
.filter(|c| suppressed.contains_key(c.as_path()))
|
|
.cloned()
|
|
.or_else(|| {
|
|
if suppressed.contains_key(path) {
|
|
Some(path.clone())
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
if let Some(key) = matched_key {
|
|
if let Some(count) = suppressed.get_mut(&key) {
|
|
*count = count.saturating_sub(1);
|
|
if *count == 0 {
|
|
suppressed.remove(&key);
|
|
}
|
|
}
|
|
drop(suppressed);
|
|
// Still commit so VFS stays consistent, but skip patching.
|
|
self.vfs
|
|
.commit_event(&event)
|
|
.expect("Error applying VFS change");
|
|
log::info!(
|
|
"VFS event SUPPRESSED (API syncback echo): {}",
|
|
path.display()
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Update the VFS immediately with the event.
|
|
self.vfs
|
|
.commit_event(&event)
|
|
.expect("Error applying VFS change");
|
|
|
|
// For a given VFS event, we might have many changes to different parts
|
|
// of the tree. Calculate and apply all of these changes.
|
|
let applied_patches = match event {
|
|
VfsEvent::Create(path) | VfsEvent::Write(path) => {
|
|
// The path might have been deleted before we could process this event
|
|
// (e.g., during syncback when directories are being removed).
|
|
// If canonicalize fails, retry once after a brief delay — on Windows,
|
|
// the file may still be locked by the editor process.
|
|
match self.vfs.canonicalize(&path) {
|
|
Ok(canonical_path) => {
|
|
log::info!(
|
|
"VFS: canonicalize OK for {} -> {}",
|
|
path.display(),
|
|
canonical_path.display()
|
|
);
|
|
self.apply_patches(canonical_path)
|
|
}
|
|
Err(err) => {
|
|
log::info!(
|
|
"VFS: canonicalize FAILED for {} ({}), retrying after 50ms...",
|
|
path.display(),
|
|
err
|
|
);
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
match self.vfs.canonicalize(&path) {
|
|
Ok(canonical_path) => {
|
|
log::info!(
|
|
"VFS: retry canonicalize OK for {} -> {}",
|
|
path.display(),
|
|
canonical_path.display()
|
|
);
|
|
self.apply_patches(canonical_path)
|
|
}
|
|
Err(err2) => {
|
|
log::warn!(
|
|
"VFS: Create/Write event DROPPED for {} after retry — \
|
|
canonicalize failed: {}. File may be desynchronized!",
|
|
path.display(),
|
|
err2
|
|
);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
VfsEvent::Remove(path) => {
|
|
// MemoFS does not track parent removals yet, so we can canonicalize
|
|
// the parent path safely and then append the removed path's file name.
|
|
// However, if the parent was also deleted (e.g., when deleting a directory
|
|
// tree), canonicalize will fail - in that case, just skip this event.
|
|
let parent = path.parent().unwrap();
|
|
let file_name = path.file_name().unwrap();
|
|
match self.vfs.canonicalize(parent) {
|
|
Ok(parent_normalized) => {
|
|
let resolved = parent_normalized.join(file_name);
|
|
log::info!("VFS: Remove resolved to {}", resolved.display());
|
|
self.apply_patches(resolved)
|
|
}
|
|
Err(err) => {
|
|
log::info!(
|
|
"VFS: Skipping remove event for {} — parent no longer exists: {}",
|
|
path.display(),
|
|
err
|
|
);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
log::warn!("Unhandled VFS event: {:?}", event);
|
|
Vec::new()
|
|
}
|
|
};
|
|
|
|
// Log patch application summary at INFO level
|
|
if !applied_patches.is_empty() {
|
|
let total_added: usize = applied_patches.iter().map(|p| p.added.len()).sum();
|
|
let total_removed: usize = applied_patches.iter().map(|p| p.removed.len()).sum();
|
|
let total_updated: usize = applied_patches.iter().map(|p| p.updated.len()).sum();
|
|
if total_added > 0 || total_removed > 0 || total_updated > 0 {
|
|
log::info!(
|
|
"VFS event applied: {} added, {} removed, {} updated",
|
|
total_added,
|
|
total_removed,
|
|
total_updated
|
|
);
|
|
}
|
|
} else {
|
|
log::info!("VFS event applied: no changes");
|
|
}
|
|
|
|
// Notify anyone listening to the message queue about the changes we
|
|
// just made.
|
|
self.message_queue.push_messages(&applied_patches);
|
|
}
|
|
|
|
/// Processes any pending recovery checks for paths that were recently
|
|
/// removed. If a path has reappeared on the real filesystem after the
|
|
/// recovery delay, we trigger a re-snapshot to bring the tree back in sync.
|
|
fn process_pending_recoveries(&self) {
|
|
const RECOVERY_DELAY: Duration = Duration::from_millis(200);
|
|
|
|
let ready: Vec<PathBuf> = {
|
|
let mut pending = self.pending_recovery.lock().unwrap();
|
|
let now = Instant::now();
|
|
|
|
// Drain entries that are old enough to check
|
|
let mut ready = Vec::new();
|
|
pending.retain(|(path, recorded_at)| {
|
|
if now.duration_since(*recorded_at) >= RECOVERY_DELAY {
|
|
ready.push(path.clone());
|
|
false // remove from pending
|
|
} else {
|
|
true // keep in pending
|
|
}
|
|
});
|
|
ready
|
|
};
|
|
|
|
for path in ready {
|
|
if std::fs::metadata(&path).is_ok() {
|
|
log::info!(
|
|
"VFS recovery: path {} was removed but has reappeared on disk. Re-snapshotting.",
|
|
path.display()
|
|
);
|
|
let patches = self.apply_patches(path);
|
|
if !patches.is_empty() {
|
|
let total_added: usize = patches.iter().map(|p| p.added.len()).sum();
|
|
let total_removed: usize = patches.iter().map(|p| p.removed.len()).sum();
|
|
let total_updated: usize = patches.iter().map(|p| p.updated.len()).sum();
|
|
log::info!(
|
|
"VFS recovery applied: {} added, {} removed, {} updated",
|
|
total_added,
|
|
total_removed,
|
|
total_updated
|
|
);
|
|
self.message_queue.push_messages(&patches);
|
|
}
|
|
} else {
|
|
log::info!(
|
|
"VFS recovery: path {} confirmed removed from disk.",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_tree_event(&self, patch_set: PatchSet) {
|
|
// Log incoming patch summary at debug level
|
|
log::debug!(
|
|
"Processing client patch: {} removed, {} added, {} updated",
|
|
patch_set.removed_instances.len(),
|
|
patch_set.added_instances.len(),
|
|
patch_set.updated_instances.len()
|
|
);
|
|
|
|
let applied_patch = {
|
|
let mut tree = self.tree.lock().unwrap();
|
|
|
|
for &id in &patch_set.removed_instances {
|
|
if let Some(instance) = tree.get_instance(id) {
|
|
if let Some(instigating_source) = &instance.metadata().instigating_source {
|
|
match instigating_source {
|
|
InstigatingSource::Path(path) => {
|
|
// Guard: file may already be deleted by the API's
|
|
// syncback_removed_instance before this PatchSet arrives.
|
|
if path.exists() {
|
|
if path.is_dir() {
|
|
log::info!(
|
|
"Two-way sync: Removing directory {}",
|
|
path.display()
|
|
);
|
|
if let Err(err) = fs::remove_dir_all(path) {
|
|
log::error!(
|
|
"Failed to remove directory {:?} for instance {:?}: {}",
|
|
path,
|
|
id,
|
|
err
|
|
);
|
|
}
|
|
} else {
|
|
log::info!(
|
|
"Two-way sync: Removing file {}",
|
|
path.display()
|
|
);
|
|
if let Err(err) = fs::remove_file(path) {
|
|
log::error!(
|
|
"Failed to remove file {:?} for instance {:?}: {}",
|
|
path,
|
|
id,
|
|
err
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
log::info!(
|
|
"Two-way sync: File already removed: {}",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
InstigatingSource::ProjectNode { .. } => {
|
|
log::warn!(
|
|
"Cannot remove instance {:?}, it's from a project file",
|
|
id
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
log::warn!(
|
|
"Cannot remove instance {:?}, it is not an instigating source.",
|
|
id
|
|
);
|
|
}
|
|
} else {
|
|
log::warn!("Cannot remove instance {:?}, it does not exist.", id);
|
|
}
|
|
}
|
|
|
|
// Collect (Ref, new_instigating_source) for instances whose
|
|
// filesystem path changed (rename / ClassName transition).
|
|
// Applied after the PatchSet to keep metadata in sync.
|
|
let mut metadata_updates: Vec<(Ref, PathBuf)> = Vec::new();
|
|
|
|
for update in &patch_set.updated_instances {
|
|
let id = update.id;
|
|
|
|
if let Some(instance) = tree.get_instance(id) {
|
|
// Track the current source file path — rename and ClassName
|
|
// handlers may move the file, so the Source write must target
|
|
// the new location instead of the stale instigating_source.
|
|
let mut overridden_source_path: Option<PathBuf> = None;
|
|
|
|
// Handle instance rename on disk
|
|
if let Some(ref new_name) = update.changed_name {
|
|
if let Some(instigating_source) = &instance.metadata().instigating_source {
|
|
match instigating_source {
|
|
InstigatingSource::Path(path) => {
|
|
if path.exists() {
|
|
let file_name =
|
|
path.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
|
|
|
// For directory-format scripts, the instigating_source
|
|
// is the init file (e.g., src/MyModule/init.luau).
|
|
// The instance name corresponds to the parent directory,
|
|
// not the init file, so we must rename the directory.
|
|
let is_init_file = file_name.starts_with("init.");
|
|
|
|
if is_init_file {
|
|
let dir_path = path.parent().unwrap();
|
|
if let Some(grandparent) = dir_path.parent() {
|
|
// Use the encoded dir name from the filesystem
|
|
// for the old meta lookup, and encode the new
|
|
// name for the new path. Using instance.name()
|
|
// (decoded) would fail for encoded names.
|
|
let dir_name = dir_path
|
|
.file_name()
|
|
.and_then(|f| f.to_str())
|
|
.unwrap_or("");
|
|
let encoded_new_name = encode_path_name(new_name);
|
|
let new_dir_path =
|
|
grandparent.join(&encoded_new_name);
|
|
|
|
if new_dir_path != dir_path {
|
|
log::info!(
|
|
"Two-way sync: Renaming directory {} -> {}",
|
|
dir_path.display(),
|
|
new_dir_path.display()
|
|
);
|
|
self.suppress_path(dir_path);
|
|
self.suppress_path(&new_dir_path);
|
|
if let Err(err) =
|
|
fs::rename(dir_path, &new_dir_path)
|
|
{
|
|
log::error!(
|
|
"Failed to rename directory {:?} to {:?}: {}",
|
|
dir_path,
|
|
new_dir_path,
|
|
err
|
|
);
|
|
} else {
|
|
// The init file moved with the directory.
|
|
overridden_source_path =
|
|
Some(new_dir_path.join(file_name));
|
|
let old_meta = grandparent.join(format!(
|
|
"{}.meta.json5",
|
|
dir_name
|
|
));
|
|
if old_meta.exists() {
|
|
let new_meta =
|
|
grandparent.join(format!(
|
|
"{}.meta.json5",
|
|
encoded_new_name
|
|
));
|
|
self.suppress_path(&old_meta);
|
|
self.suppress_path(&new_meta);
|
|
let _ =
|
|
fs::rename(&old_meta, &new_meta);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if let Some(parent) = path.parent() {
|
|
// Derive the suffix from the existing filename
|
|
// rather than using replacen with the decoded
|
|
// instance name, which fails for encoded names
|
|
// (e.g., What%QUESTION%.server.luau).
|
|
let extension = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("");
|
|
let stem = path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("");
|
|
let known_suffixes = [
|
|
".server", ".client", ".plugin", ".local",
|
|
".legacy",
|
|
];
|
|
let script_suffix = known_suffixes
|
|
.iter()
|
|
.find(|s| stem.ends_with(*s))
|
|
.copied()
|
|
.unwrap_or("");
|
|
let old_base = if script_suffix.is_empty() {
|
|
stem
|
|
} else {
|
|
&stem[..stem.len() - script_suffix.len()]
|
|
};
|
|
|
|
let encoded_new_name = encode_path_name(new_name);
|
|
let new_file_name = if extension.is_empty() {
|
|
format!("{}{}", encoded_new_name, script_suffix)
|
|
} else {
|
|
format!(
|
|
"{}{}.{}",
|
|
encoded_new_name, script_suffix, extension
|
|
)
|
|
};
|
|
let new_path = parent.join(&new_file_name);
|
|
|
|
if new_path != *path {
|
|
log::info!(
|
|
"Two-way sync: Renaming {} -> {}",
|
|
path.display(),
|
|
new_path.display()
|
|
);
|
|
self.suppress_path(path);
|
|
self.suppress_path(&new_path);
|
|
if let Err(err) = fs::rename(path, &new_path) {
|
|
log::error!(
|
|
"Failed to rename {:?} to {:?}: {}",
|
|
path,
|
|
new_path,
|
|
err
|
|
);
|
|
} else {
|
|
overridden_source_path = Some(new_path.clone());
|
|
let old_meta = parent
|
|
.join(format!("{}.meta.json5", old_base));
|
|
if old_meta.exists() {
|
|
let new_meta = parent.join(format!(
|
|
"{}.meta.json5",
|
|
encoded_new_name
|
|
));
|
|
self.suppress_path(&old_meta);
|
|
self.suppress_path(&new_meta);
|
|
let _ = fs::rename(&old_meta, &new_meta);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
InstigatingSource::ProjectNode { .. } => {
|
|
log::warn!(
|
|
"Cannot rename instance {:?} — defined in project file",
|
|
id
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
log::warn!("Cannot rename instance {:?} — no instigating source", id);
|
|
}
|
|
}
|
|
|
|
// Handle ClassName changes (script class transitions)
|
|
if let Some(ref new_class) = update.changed_class_name {
|
|
if let Some(instigating_source) = &instance.metadata().instigating_source {
|
|
match instigating_source {
|
|
InstigatingSource::Path(path) => {
|
|
let old_class = instance.class_name();
|
|
let old_is_script = matches!(
|
|
old_class.as_str(),
|
|
"ModuleScript" | "Script" | "LocalScript"
|
|
);
|
|
let new_is_script = matches!(
|
|
new_class.as_str(),
|
|
"ModuleScript" | "Script" | "LocalScript"
|
|
);
|
|
|
|
// If a rename handler already moved the file,
|
|
// use the new path instead of the stale
|
|
// instigating_source path.
|
|
let effective_path =
|
|
overridden_source_path.as_deref().unwrap_or(path.as_path());
|
|
|
|
if old_is_script && new_is_script && effective_path.exists() {
|
|
// Script-to-script transition: rename the file extension.
|
|
// For directory-format scripts, the path is the directory
|
|
// (e.g., src/MyModule/), not the init file inside. We must
|
|
// find and rename the init file, not the directory.
|
|
// Resolve the actual file to rename. For directories,
|
|
// find the init file inside; for files, use directly.
|
|
let init_result = if effective_path.is_dir() {
|
|
Self::find_init_file(effective_path)
|
|
.map(|f| (f.clone(), effective_path.to_path_buf()))
|
|
} else {
|
|
Some((
|
|
effective_path.to_path_buf(),
|
|
effective_path
|
|
.parent()
|
|
.unwrap_or(effective_path)
|
|
.to_path_buf(),
|
|
))
|
|
};
|
|
|
|
if let Some((actual_file, file_parent)) = init_result {
|
|
let new_suffix = match new_class.as_str() {
|
|
"ModuleScript" => "",
|
|
"Script" => ".server",
|
|
"LocalScript" => ".local",
|
|
_ => "",
|
|
};
|
|
let is_init = actual_file
|
|
.file_name()
|
|
.and_then(|f| f.to_str())
|
|
.map(|f| f.starts_with("init."))
|
|
.unwrap_or(false);
|
|
|
|
let new_file_name = if is_init {
|
|
if new_suffix.is_empty() {
|
|
"init.luau".to_string()
|
|
} else {
|
|
format!("init{}.luau", new_suffix)
|
|
}
|
|
} else {
|
|
// Derive the encoded base name from the
|
|
// filesystem path, not instance.name()
|
|
// (which is decoded). This preserves
|
|
// encoding like What%QUESTION% on disk.
|
|
let stem = actual_file
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("");
|
|
let known_suffixes = [
|
|
".server", ".client", ".plugin", ".local",
|
|
".legacy",
|
|
];
|
|
let base = known_suffixes
|
|
.iter()
|
|
.find_map(|s| stem.strip_suffix(s))
|
|
.unwrap_or(stem);
|
|
if new_suffix.is_empty() {
|
|
format!("{}.luau", base)
|
|
} else {
|
|
format!("{}{}.luau", base, new_suffix)
|
|
}
|
|
};
|
|
|
|
let new_path = file_parent.join(&new_file_name);
|
|
if new_path != actual_file {
|
|
log::info!(
|
|
"Two-way sync: Changing class {} -> {}, \
|
|
renaming {} -> {}",
|
|
old_class,
|
|
new_class,
|
|
actual_file.display(),
|
|
new_path.display()
|
|
);
|
|
self.suppress_path(&actual_file);
|
|
self.suppress_path(&new_path);
|
|
if let Err(err) =
|
|
fs::rename(&actual_file, &new_path)
|
|
{
|
|
log::error!(
|
|
"Failed to rename {:?} to {:?}: {}",
|
|
actual_file,
|
|
new_path,
|
|
err
|
|
);
|
|
} else {
|
|
overridden_source_path = Some(new_path.clone());
|
|
}
|
|
}
|
|
} else {
|
|
log::warn!(
|
|
"Cannot change ClassName for directory {} \
|
|
— no init file found inside",
|
|
path.display()
|
|
);
|
|
}
|
|
} else if old_is_script != new_is_script {
|
|
log::warn!(
|
|
"Cannot change ClassName from {} to {} — \
|
|
cross-category changes (script <-> non-script) \
|
|
are not yet supported",
|
|
old_class,
|
|
new_class
|
|
);
|
|
}
|
|
}
|
|
InstigatingSource::ProjectNode { .. } => {
|
|
log::warn!(
|
|
"Cannot change ClassName for {:?} — defined in project file",
|
|
id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if update.changed_metadata.is_some() {
|
|
log::warn!("Cannot change metadata yet.");
|
|
}
|
|
|
|
for (key, changed_value) in &update.changed_properties {
|
|
if key == "Source" {
|
|
// If a rename or ClassName change moved the file
|
|
// earlier in this update, write to the new location
|
|
// instead of the stale instigating_source path.
|
|
let source_path = if let Some(ref overridden) = overridden_source_path {
|
|
Some(overridden.clone())
|
|
} else if let Some(instigating_source) =
|
|
&instance.metadata().instigating_source
|
|
{
|
|
match instigating_source {
|
|
InstigatingSource::Path(path) => Some(path.clone()),
|
|
InstigatingSource::ProjectNode { .. } => {
|
|
log::warn!(
|
|
"Cannot update instance {:?}, it's from a project file",
|
|
id
|
|
);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
log::warn!(
|
|
"Cannot update instance {:?}, it is not an instigating source.",
|
|
id
|
|
);
|
|
None
|
|
};
|
|
|
|
if let Some(ref write_path) = source_path {
|
|
if let Some(Variant::String(value)) = changed_value {
|
|
log::info!(
|
|
"Two-way sync: Writing Source to {}",
|
|
write_path.display()
|
|
);
|
|
self.suppress_path(write_path);
|
|
if let Err(err) = fs::write(write_path, value) {
|
|
log::error!(
|
|
"Failed to write Source to {:?} for instance {:?}: {}",
|
|
write_path,
|
|
id,
|
|
err
|
|
);
|
|
}
|
|
} else {
|
|
log::warn!("Cannot change Source to non-string value.");
|
|
}
|
|
}
|
|
} else {
|
|
log::trace!("Skipping non-Source property change: {}", key);
|
|
}
|
|
}
|
|
|
|
// Record metadata update so instigating_source and
|
|
// relevant_paths stay in sync after rename / class change.
|
|
if let Some(ref new_path) = overridden_source_path {
|
|
metadata_updates.push((id, new_path.clone()));
|
|
}
|
|
} else {
|
|
log::warn!("Cannot update instance {:?}, it does not exist.", id);
|
|
}
|
|
}
|
|
|
|
let applied = apply_patch_set(&mut tree, patch_set);
|
|
|
|
// Update metadata for instances whose filesystem path changed.
|
|
// This keeps instigating_source and path_to_ids in sync so that
|
|
// subsequent VFS events (and future renames) target the correct
|
|
// instance and path.
|
|
for (id, new_instigating_source) in metadata_updates {
|
|
if let Some(old_metadata) = tree.get_metadata(id).cloned() {
|
|
let mut new_metadata = old_metadata;
|
|
new_metadata.instigating_source =
|
|
Some(InstigatingSource::Path(new_instigating_source.clone()));
|
|
new_metadata.relevant_paths =
|
|
rebuild_relevant_paths(&new_instigating_source);
|
|
tree.update_metadata(id, new_metadata);
|
|
}
|
|
}
|
|
|
|
applied
|
|
};
|
|
|
|
if !applied_patch.is_empty() {
|
|
self.message_queue.push_messages(&[applied_patch]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result of computing and applying changes to a single instance.
|
|
/// Includes the path that was removed (if any) for pending recovery tracking.
|
|
struct ComputeResult {
|
|
applied: AppliedPatchSet,
|
|
/// If the instance was removed because its path no longer exists,
|
|
/// record the path here so the caller can schedule a recovery check.
|
|
removed_path: Option<PathBuf>,
|
|
}
|
|
|
|
fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<ComputeResult> {
|
|
let metadata = tree
|
|
.get_metadata(id)
|
|
.expect("metadata missing for instance present in tree");
|
|
|
|
let instigating_source = match &metadata.instigating_source {
|
|
Some(path) => path,
|
|
None => {
|
|
log::error!(
|
|
"Instance {:?} did not have an instigating source, but was considered for an update.",
|
|
id
|
|
);
|
|
log::error!("This is a bug. Please file an issue!");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
// How we process a file change event depends on what created this
|
|
// file/folder in the first place.
|
|
match instigating_source {
|
|
InstigatingSource::Path(path) => {
|
|
// For directory-format scripts, the instigating_source is the
|
|
// init file (e.g., init.luau) so that two-way sync writes to the
|
|
// correct file. However, snapshot_from_vfs returns None for init
|
|
// files because they are handled as part of the parent directory
|
|
// snapshot. Detect this case and snapshot the parent directory.
|
|
let is_init_file = path
|
|
.file_name()
|
|
.and_then(|f| f.to_str())
|
|
.map(|f| f.starts_with("init."))
|
|
.unwrap_or(false);
|
|
let snapshot_path = if is_init_file {
|
|
path.parent().unwrap_or(path.as_path())
|
|
} else {
|
|
path.as_path()
|
|
};
|
|
|
|
log::info!(
|
|
"compute_and_apply_changes: checking path {} for instance {:?}{}",
|
|
path.display(),
|
|
id,
|
|
if is_init_file {
|
|
format!(
|
|
" (init file, will snapshot parent dir {})",
|
|
snapshot_path.display()
|
|
)
|
|
} else {
|
|
String::new()
|
|
}
|
|
);
|
|
|
|
match vfs.metadata(path).with_not_found() {
|
|
Ok(Some(_)) => {
|
|
// Our instance was previously created from a path and that
|
|
// path still exists. We can generate a snapshot starting at
|
|
// that path and use it as the source for our patch.
|
|
log::info!(
|
|
"compute_and_apply_changes: path EXISTS via VFS, re-snapshotting {}",
|
|
snapshot_path.display()
|
|
);
|
|
|
|
let snapshot = match snapshot_from_vfs(&metadata.context, vfs, snapshot_path) {
|
|
Ok(snapshot) => snapshot,
|
|
Err(err) => {
|
|
log::error!("Snapshot error: {:?}", err);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let patch_set = compute_patch_set(snapshot, tree, id);
|
|
let applied = apply_patch_set(tree, patch_set);
|
|
Some(ComputeResult {
|
|
applied,
|
|
removed_path: None,
|
|
})
|
|
}
|
|
Ok(None) => {
|
|
// Path not found via VFS. Before removing, verify it's truly
|
|
// gone from the REAL filesystem. On Windows, rapid
|
|
// delete+recreate (e.g., editor undo) can cause Remove events
|
|
// where the file has already been recreated by the time we
|
|
// process the event.
|
|
if std::fs::metadata(path).is_ok() {
|
|
log::info!(
|
|
"compute_and_apply_changes: VFS says path removed but REAL \
|
|
filesystem confirms it EXISTS: {}. Re-snapshotting instead \
|
|
of removing.",
|
|
path.display()
|
|
);
|
|
|
|
let snapshot =
|
|
match snapshot_from_vfs(&metadata.context, vfs, snapshot_path) {
|
|
Ok(snapshot) => snapshot,
|
|
Err(err) => {
|
|
log::error!(
|
|
"Recovery snapshot error for {}: {:?}",
|
|
snapshot_path.display(),
|
|
err
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let patch_set = compute_patch_set(snapshot, tree, id);
|
|
let applied = apply_patch_set(tree, patch_set);
|
|
Some(ComputeResult {
|
|
applied,
|
|
removed_path: None,
|
|
})
|
|
} else if is_init_file && std::fs::metadata(snapshot_path).is_ok() {
|
|
// Init file was deleted but the parent directory still
|
|
// exists. Re-snapshot the directory — it will become a
|
|
// Folder (or whatever the directory middleware produces
|
|
// without an init file).
|
|
log::info!(
|
|
"compute_and_apply_changes: init file {} deleted but parent \
|
|
directory {} still exists. Re-snapshotting directory.",
|
|
path.display(),
|
|
snapshot_path.display()
|
|
);
|
|
|
|
let snapshot =
|
|
match snapshot_from_vfs(&metadata.context, vfs, snapshot_path) {
|
|
Ok(snapshot) => snapshot,
|
|
Err(err) => {
|
|
log::error!(
|
|
"Directory re-snapshot error for {}: {:?}",
|
|
snapshot_path.display(),
|
|
err
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let patch_set = compute_patch_set(snapshot, tree, id);
|
|
let applied = apply_patch_set(tree, patch_set);
|
|
Some(ComputeResult {
|
|
applied,
|
|
removed_path: None,
|
|
})
|
|
} else {
|
|
// Path is genuinely gone from both VFS and real filesystem.
|
|
// Remove the instance, but record the path for recovery
|
|
// checking — the file might be recreated momentarily.
|
|
log::info!(
|
|
"compute_and_apply_changes: path NOT FOUND on disk, removing \
|
|
instance {:?} for {}. Scheduling recovery check.",
|
|
id,
|
|
path.display()
|
|
);
|
|
|
|
let removed_path = path.to_path_buf();
|
|
let mut patch_set = PatchSet::new();
|
|
patch_set.removed_instances.push(id);
|
|
|
|
let applied = apply_patch_set(tree, patch_set);
|
|
Some(ComputeResult {
|
|
applied,
|
|
removed_path: Some(removed_path),
|
|
})
|
|
}
|
|
}
|
|
Err(err) => {
|
|
log::error!(
|
|
"Error processing filesystem change for {}: {:?}",
|
|
path.display(),
|
|
err
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
InstigatingSource::ProjectNode {
|
|
path,
|
|
name,
|
|
node,
|
|
parent_class,
|
|
} => {
|
|
// This instance is the direct subject of a project node. Since
|
|
// there might be information associated with our instance from
|
|
// the project file, we snapshot the entire project node again.
|
|
log::info!(
|
|
"compute_and_apply_changes: re-snapshotting project node '{}' at {}",
|
|
name,
|
|
path.display()
|
|
);
|
|
|
|
let snapshot_result = snapshot_project_node(
|
|
&metadata.context,
|
|
path,
|
|
name,
|
|
node,
|
|
vfs,
|
|
parent_class.as_ref().map(|name| name.as_str()),
|
|
);
|
|
|
|
let snapshot = match snapshot_result {
|
|
Ok(snapshot) => snapshot,
|
|
Err(err) => {
|
|
log::error!("{:?}", err);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let patch_set = compute_patch_set(snapshot, tree, id);
|
|
let applied = apply_patch_set(tree, patch_set);
|
|
Some(ComputeResult {
|
|
applied,
|
|
removed_path: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rebuild the `relevant_paths` list for an instance given its new
|
|
/// `instigating_source` path. This mirrors the logic in the snapshot
|
|
/// middleware so that `path_to_ids` stays correct after a rename.
|
|
fn rebuild_relevant_paths(new_path: &Path) -> Vec<PathBuf> {
|
|
let file_name = new_path
|
|
.file_name()
|
|
.and_then(|f| f.to_str())
|
|
.unwrap_or("");
|
|
let is_init = file_name.starts_with("init.");
|
|
|
|
if is_init {
|
|
// Directory-format script: relevant paths are the directory itself
|
|
// plus every possible init file variant inside it.
|
|
let dir_path = new_path.parent().unwrap().to_path_buf();
|
|
vec![
|
|
dir_path.clone(),
|
|
dir_path.join("init.luau"),
|
|
dir_path.join("init.server.luau"),
|
|
dir_path.join("init.client.luau"),
|
|
dir_path.join("init.local.luau"),
|
|
dir_path.join("init.legacy.luau"),
|
|
dir_path.join("init.csv"),
|
|
dir_path.join("init.lua"),
|
|
dir_path.join("init.server.lua"),
|
|
dir_path.join("init.client.lua"),
|
|
dir_path.join("init.meta.json5"),
|
|
]
|
|
} else {
|
|
// Standalone file: relevant paths are the file itself and its
|
|
// adjacent meta file.
|
|
let stem = new_path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("");
|
|
let base_name = stem
|
|
.strip_suffix(".server")
|
|
.or_else(|| stem.strip_suffix(".client"))
|
|
.or_else(|| stem.strip_suffix(".plugin"))
|
|
.or_else(|| stem.strip_suffix(".local"))
|
|
.or_else(|| stem.strip_suffix(".legacy"))
|
|
.unwrap_or(stem);
|
|
vec![
|
|
new_path.to_path_buf(),
|
|
new_path.with_file_name(format!("{base_name}.meta.json5")),
|
|
]
|
|
}
|
|
}
|