rojo/src/change_processor.rs
Joe 2ea1d49316 Enhance VFS watch functionality and introduce recording mode
- Updated the `watch` method in `VfsBackend` to accept a `recursive` parameter, allowing for more flexible directory watching.
- Implemented a recording mode in the `Vfs` struct to track accessed paths without immediately watching them, improving performance during read operations.
- Added tests to verify the behavior of the new recording mode and ensure correct path tracking.
- Enhanced the `StdBackend` and `NoopBackend` implementations to support the updated watch functionality.

These changes aim to improve the VFS's ability to manage file system events and enhance user control over watching behavior.
2026-03-06 17:25:38 -06:00

2156 lines
106 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::collections::HashSet;
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use std::{
fs,
sync::{Arc, Mutex},
};
use crate::{
message_queue::MessageQueue,
snapshot::{
apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree,
},
snapshot_middleware::{is_script_relevant_path, snapshot_from_vfs, snapshot_project_node},
syncback::{
dedup_suffix::{compute_cleanup_action, parse_dedup_suffix, DedupCleanupAction},
deduplicate_name, name_needs_slugify, slugify_name, strip_script_suffix,
},
};
/// Set to `true` to enable periodic tree reconciliation after VFS events.
/// Currently disabled: the per-event VFS handler handles file changes correctly,
/// and the reconciler has a persistent drift bug that causes an infinite
/// 12-14s re-snapshot loop on large projects.
const ENABLE_TREE_RECONCILIATION: bool = false;
/// Wrapper that displays a path relative to a project root directory.
struct RelPath<'a> {
path: &'a Path,
root: &'a Path,
}
impl fmt::Display for RelPath<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.path
.strip_prefix(self.root)
.unwrap_or(self.path)
.display()
.fmt(f)
}
}
/// Returns a display wrapper that shows `path` relative to `root`.
fn rel_path<'a>(path: &'a Path, root: &'a Path) -> RelPath<'a> {
RelPath { path, root }
}
/// 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.
#[allow(clippy::too_many_arguments)]
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, usize)>>>,
ref_path_index: Arc<Mutex<crate::RefPathIndex>>,
project_root: PathBuf,
project_file_path: PathBuf,
critical_error_receiver: Option<Receiver<memofs::WatcherCriticalError>>,
git_repo_root: Option<PathBuf>,
sync_scripts_only: bool,
) -> Self {
let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1);
let vfs_receiver = vfs.event_receiver();
// Use crossbeam::never() for callers that don't provide an error receiver
// (non-serve commands). never() blocks forever without selecting.
let critical_error_receiver =
critical_error_receiver.unwrap_or_else(crossbeam_channel::never);
let task = JobThreadContext {
tree,
vfs,
message_queue,
pending_recovery: Mutex::new(Vec::new()),
suppressed_paths,
project_root,
project_file_path,
ref_path_index,
git_repo_root,
sync_scripts_only,
};
let job_thread = jod_thread::Builder::new()
.name("ChangeProcessor thread".to_owned())
.spawn(move || {
log::trace!("ChangeProcessor thread started");
// Tracks when to run the next reconciliation pass. Set to
// Some(future_instant) after VFS events arrive, cleared
// after reconcile_tree() runs. This ensures we only do the
// full re-snapshot once per burst of activity.
let mut reconcile_at: Option<Instant> = None;
loop {
// Compute the timeout for the default branch.
// If a reconciliation is pending, wake up when it's due
// (clamped to at least 50ms to avoid busy-spinning).
// Otherwise use the normal 500ms sweep interval.
let timeout = match reconcile_at {
Some(deadline) => {
let remaining = deadline.saturating_duration_since(Instant::now());
remaining.max(Duration::from_millis(50))
}
None => Duration::from_millis(500),
};
select! {
recv(vfs_receiver) -> event => {
let mut all_patches = task.handle_vfs_event(event?);
// Drain any pending events that arrived during processing.
// This ensures that multi-event filesystem operations (e.g.,
// rename = REMOVE + CREATE on Windows) produce a single
// batched message instead of separate per-event messages,
// giving consistent behavior across platforms.
while let Ok(event) = vfs_receiver.try_recv() {
all_patches.extend(task.handle_vfs_event(event));
}
all_patches.extend(task.process_pending_recoveries());
if !all_patches.is_empty() {
let merged = AppliedPatchSet::merge(all_patches);
if !merged.is_empty() {
task.message_queue.push_messages(&[merged]);
}
}
if ENABLE_TREE_RECONCILIATION {
// Schedule a reconciliation 200ms from now if one isn't pending.
if reconcile_at.is_none() {
reconcile_at = Some(Instant::now() + Duration::from_millis(200));
}
// If the deadline has passed, reconcile now. This check runs
// inside the VFS branch because during sustained event bursts,
// the `default` branch never fires (the VFS channel is never
// idle long enough).
if reconcile_at.is_some_and(|d| Instant::now() >= d) {
task.reconcile_tree();
reconcile_at = None;
}
}
},
recv(tree_mutation_receiver) -> patch_set => {
task.handle_tree_event(patch_set?);
},
recv(critical_error_receiver) -> err => {
if let Ok(memofs::WatcherCriticalError::RescanRequired) = err {
if ENABLE_TREE_RECONCILIATION {
log::warn!(
"VFS watcher lost events (RescanRequired). \
Triggering full tree reconciliation."
);
task.reconcile_tree();
reconcile_at = None;
} else {
log::warn!(
"VFS watcher lost events (RescanRequired). \
Tree reconciliation is disabled; some changes may be missed."
);
}
}
},
recv(shutdown_receiver) -> _ => {
log::trace!("ChangeProcessor shutdown signal received...");
return Ok(());
},
default(timeout) => {
task.process_pending_recoveries();
if ENABLE_TREE_RECONCILIATION {
// If a reconciliation deadline has passed, run it now.
if reconcile_at.is_some_and(|d| Instant::now() >= d) {
task.reconcile_tree();
reconcile_at = None;
}
}
},
}
}
})
.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 `(remove_count, create_write_count)`.
suppressed_paths: Arc<Mutex<std::collections::HashMap<PathBuf, (usize, usize)>>>,
/// Root directory of the project, used to display relative paths in logs.
project_root: PathBuf,
/// Path to the project file (e.g. `plugin.project.json`). Used by
/// `reconcile_tree` so that re-snapshotting goes through the project
/// middleware instead of walking the directory as a generic Folder.
project_file_path: PathBuf,
/// Index of meta/model files that contain `Rojo_Ref_*` attributes.
/// Shared with ApiService for efficient rename path updates.
ref_path_index: Arc<Mutex<crate::RefPathIndex>>,
/// Git repository root, if the project is in a git repo.
/// Used for auto-staging Source writes.
git_repo_root: Option<PathBuf>,
/// When true, only script-related VFS events are processed.
sync_scripts_only: bool,
}
impl JobThreadContext {
/// Returns a display wrapper that shows `path` relative to the project root.
fn display_path<'a>(&'a self, path: &'a Path) -> RelPath<'a> {
rel_path(path, &self.project_root)
}
/// 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
}
fn suppression_key(path: &Path) -> PathBuf {
path.to_path_buf()
}
/// Suppress the next Create/Write VFS event for the given path.
/// Prevents re-processing a file-watcher event we triggered ourselves.
fn suppress_path(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = Self::suppression_key(path);
suppressed.entry(key).or_insert((0, 0)).1 += 1;
}
/// Remove a Create/Write suppression previously added by [`suppress_path`].
/// Called when the filesystem operation failed, so that future VFS events
/// for that path are not incorrectly swallowed.
fn unsuppress_path(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = Self::suppression_key(path);
if let Some(counts) = suppressed.get_mut(&key) {
counts.1 = counts.1.saturating_sub(1);
if counts.0 == 0 && counts.1 == 0 {
suppressed.remove(&key);
}
}
}
/// Suppress the next VFS event of ANY type for the given path.
/// Used for the **old** path of renames: different platforms deliver
/// different event types for the source of a rename (REMOVE on
/// Linux/Windows, stale CREATE on macOS FSEvents).
fn suppress_path_any(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = Self::suppression_key(path);
let entry = suppressed.entry(key).or_insert((0, 0));
entry.0 += 1;
entry.1 += 1;
}
/// Remove both counters previously added by [`suppress_path_any`].
fn unsuppress_path_any(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = Self::suppression_key(path);
if let Some(counts) = suppressed.get_mut(&key) {
counts.0 = counts.0.saturating_sub(1);
counts.1 = counts.1.saturating_sub(1);
if counts.0 == 0 && counts.1 == 0 {
suppressed.remove(&key);
}
}
}
/// Suppress the next Remove VFS event for the given path.
fn suppress_path_remove(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = Self::suppression_key(path);
suppressed.entry(key).or_insert((0, 0)).0 += 1;
}
/// Upsert the `name` field in a `.meta.json5` file, suppressing filesystem
/// events to avoid feedback loops.
fn upsert_meta_name_field(&self, meta_path: &Path, real_name: &str) {
self.suppress_path(meta_path);
if let Err(err) = crate::syncback::meta::upsert_meta_name(meta_path, real_name) {
self.unsuppress_path(meta_path);
log::error!(
"Failed to upsert name in meta file {}: {}",
self.display_path(meta_path),
err
);
}
}
/// Upsert the `name` field inside a `.model.json5` / `.model.json` file,
/// suppressing filesystem events.
fn upsert_model_name_field(&self, model_path: &Path, real_name: &str) {
self.suppress_path(model_path);
if let Err(err) = crate::syncback::meta::upsert_model_name(model_path, real_name) {
self.unsuppress_path(model_path);
log::error!(
"Failed to upsert name in model file {}: {}",
self.display_path(model_path),
err
);
}
}
/// Remove the `name` field from a `.model.json5` / `.model.json` file,
/// suppressing filesystem events.
fn remove_model_name_field(&self, model_path: &Path) {
use crate::syncback::meta::RemoveNameOutcome;
self.suppress_path(model_path);
match crate::syncback::meta::remove_model_name(model_path) {
Ok(RemoveNameOutcome::NoOp) => {
self.unsuppress_path(model_path);
}
Ok(RemoveNameOutcome::FieldRemoved) => {
// File was rewritten — suppress_path already covers it.
}
Ok(RemoveNameOutcome::FileDeleted) => {
// Model files shouldn't be deleted (they have className etc),
// but handle for completeness.
self.unsuppress_path(model_path);
self.suppress_path_remove(model_path);
}
Err(err) => {
self.unsuppress_path(model_path);
log::error!(
"Failed to remove name from model file {}: {}",
self.display_path(model_path),
err
);
}
}
}
/// After an instance is renamed, update all `Rojo_Ref_*` attributes on
/// disk that reference the old path prefix, replacing it with the new
/// prefix.
///
/// Uses the `RefPathIndex` for O(affected_files) lookup instead of
/// scanning the full tree. After updating files, also updates the index
/// keys and filesystem paths so future renames remain efficient.
fn update_ref_paths_after_rename(
&self,
old_path: &str,
new_path: &str,
tree: &crate::snapshot::RojoTree,
) {
if old_path == new_path {
return;
}
let files_from_index = self.ref_path_index.lock().unwrap().find_by_prefix(old_path);
if files_from_index.is_empty() {
return;
}
let old_segment = old_path.rsplit('/').next().unwrap_or(old_path);
let new_segment = new_path.rsplit('/').next().unwrap_or(new_path);
let slugified_old = if name_needs_slugify(old_segment) {
Some(slugify_name(old_segment))
} else {
None
};
let slugified_new = || -> String {
if name_needs_slugify(new_segment) {
slugify_name(new_segment)
} else {
new_segment.to_string()
}
};
let original_paths = files_from_index.clone();
let files_to_check: Vec<PathBuf> = files_from_index
.into_iter()
.map(|file_path| {
if file_path.exists() {
return file_path;
}
let mut result = PathBuf::new();
let mut replaced = false;
for comp in file_path.components() {
if !replaced {
if let std::path::Component::Normal(os_str) = comp {
if let Some(s) = os_str.to_str() {
if s == old_segment {
result.push(slugified_new());
replaced = true;
continue;
}
if let Some(ref slug) = slugified_old {
if s == slug.as_str() {
result.push(slugified_new());
replaced = true;
continue;
}
}
}
}
}
result.push(comp);
}
if replaced {
result
} else {
file_path
}
})
.collect();
let mut updated_count = 0;
for file_path in &files_to_check {
let source_abs = tree
.get_ids_at_path(file_path)
.first()
.map(|&id| crate::ref_target_path_from_tree(tree, id))
.unwrap_or_default();
self.suppress_path(file_path);
match crate::syncback::meta::update_ref_paths_in_file(
file_path,
old_path,
new_path,
&source_abs,
) {
Ok(true) => {
updated_count += 1;
}
Ok(false) => {
self.unsuppress_path(file_path);
}
Err(err) => {
self.unsuppress_path(file_path);
log::warn!(
"Failed to update Rojo_Ref_* paths in {}: {}",
self.display_path(file_path),
err
);
}
}
}
// Update the index: both the path keys AND the filesystem paths.
if updated_count > 0 {
let mut index = self.ref_path_index.lock().unwrap();
index.update_prefix(old_path, new_path);
// Also update filesystem paths in the index entries
for (old_file, new_file) in original_paths.iter().zip(files_to_check.iter()) {
if old_file != new_file {
index.rename_file(old_file, new_file);
}
}
log::info!(
"Updated Rojo_Ref_* paths in {} file(s): '{}' -> '{}'",
updated_count,
old_path,
new_path
);
}
}
/// Remove the `name` field from a `.meta.json5` file, suppressing filesystem
/// events. If the file becomes empty after removal, deletes it entirely.
fn remove_meta_name_field(&self, meta_path: &Path) {
use crate::syncback::meta::RemoveNameOutcome;
// Suppress for the write/delete that may follow
self.suppress_path(meta_path);
match crate::syncback::meta::remove_meta_name(meta_path) {
Ok(RemoveNameOutcome::NoOp) => {
self.unsuppress_path(meta_path);
}
Ok(RemoveNameOutcome::FileDeleted) => {
// File was deleted, not rewritten. Swap: undo the
// pre-emptive Write suppression and add a Remove
// suppression instead so the counts are (1, 0).
self.unsuppress_path(meta_path);
self.suppress_path_remove(meta_path);
}
Ok(RemoveNameOutcome::FieldRemoved) => {
// File was rewritten — suppress_path already covers it.
}
Err(err) => {
self.unsuppress_path(meta_path);
log::error!(
"Failed to remove name from meta file {}: {}",
self.display_path(meta_path),
err
);
}
}
}
/// 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 {:?}",
self.display_path(current_path),
ids
);
if !ids.is_empty() {
break ids.to_vec();
}
log::info!(
"apply_patches: no IDs at {}, trying parent...",
self.display_path(current_path)
);
match current_path.parent() {
// Stop walking if we've reached or passed the project root.
Some(parent) if parent.starts_with(&self.project_root) => {
current_path = parent;
}
_ => break Vec::new(),
}
};
if affected_ids.is_empty() {
log::info!(
"apply_patches: no affected instances found for path {}",
self.display_path(&path)
);
}
for id in affected_ids {
if let Some(result) =
compute_and_apply_changes(&mut tree, &self.vfs, id, &self.project_root)
{
// 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) -> Vec<AppliedPatchSet> {
// 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 {}", self.display_path(path)),
VfsEvent::Write(path) => log::info!("VFS event: WRITE {}", self.display_path(path)),
VfsEvent::Remove(path) => log::info!("VFS event: REMOVE {}", self.display_path(path)),
_ => log::info!("VFS event: OTHER {:?}", event),
}
// Check if this event should be suppressed (one-shot, from API syncback).
// Suppressions are event-type-aware: a Remove suppression only matches
// Remove events, and a Create/Write suppression only matches Create/Write
// events. This prevents a Remove suppression from consuming a Create event
// (which can happen on macOS when FSEvents coalesces delete+recreate).
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 mut suppressed = self.suppressed_paths.lock().unwrap();
let key = path.clone();
if let Some(counts) = suppressed.get_mut(key.as_path()) {
let should_suppress = match &event {
VfsEvent::Remove(_) if counts.0 > 0 => {
counts.0 -= 1;
true
}
VfsEvent::Create(_) | VfsEvent::Write(_) if counts.1 > 0 => {
counts.1 -= 1;
true
}
_ => false,
};
if counts.0 == 0 && counts.1 == 0 {
suppressed.remove(&key);
}
if should_suppress {
drop(suppressed);
self.vfs
.commit_event(&event)
.expect("Error applying VFS change");
log::info!(
"VFS event SUPPRESSED (API syncback echo): {}",
self.display_path(path)
);
return Vec::new();
}
}
}
// Update the VFS immediately with the event.
self.vfs
.commit_event(&event)
.expect("Error applying VFS change");
// On Windows, ReadDirectoryChangesW fires a directory-level WRITE event
// when a file inside the directory is modified, in addition to the
// file-level WRITE event. The directory event arrives first, but at that
// point the VFS cache still has the old file content (only the directory
// entry was committed, not the file). Re-snapshotting from the directory
// event reads stale data and produces incorrect patches. The file-level
// event that follows will correctly invalidate the cache and re-snapshot.
// Skip WRITE events for directories on Windows only -- macOS kqueue uses
// directory WRITE events meaningfully (e.g., file creation/deletion
// notifications) so they must be processed there.
#[cfg(target_os = "windows")]
if let VfsEvent::Write(ref path) = event {
if path.is_dir() {
log::info!(
"VFS event SKIPPED (directory WRITE, deferring to file events): {}",
self.display_path(path)
);
return Vec::new();
}
}
if self.sync_scripts_only {
if let Some(ref path) = event_path {
if path.is_file() {
let not_in_tree = {
let tree = self.tree.lock().unwrap();
tree.get_ids_at_path(path).is_empty()
};
if not_in_tree && !is_script_relevant_path(path) {
log::trace!(
"VFS event SKIPPED (scripts-only, non-script file): {}",
self.display_path(path)
);
return Vec::new();
}
}
}
}
// 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) => {
if path.exists() {
self.apply_patches(path)
} else {
// The path doesn't exist on disk. Two possible causes:
//
// 1. Phantom event from a rename we performed — macOS FSEvents
// can deliver a stale CREATE for the old path. If a pending
// suppression exists for this path, consume it and skip.
//
// 2. The file was deleted between the event firing and us
// processing it. Fall back to parent directory so the tree
// can reconcile the disappearance.
let consumed = {
let key = Self::suppression_key(&path);
let mut suppressed = self.suppressed_paths.lock().unwrap();
if let Some(counts) = suppressed.get_mut(&key) {
if counts.0 > 0 || counts.1 > 0 {
if counts.1 > 0 {
counts.1 -= 1;
} else {
counts.0 -= 1;
}
if counts.0 == 0 && counts.1 == 0 {
suppressed.remove(&key);
}
true
} else {
false
}
} else {
false
}
};
if consumed {
log::info!(
"VFS: phantom Create/Write for non-existent {} — \
consumed pending suppression (likely stale rename event)",
self.display_path(&path)
);
Vec::new()
} else {
let parent = path.parent().unwrap();
let file_name = path.file_name().unwrap();
if parent.exists() {
let resolved = parent.join(file_name);
log::info!(
"VFS: Create/Write for vanished file {} — \
resolved via parent to {}",
self.display_path(&path),
self.display_path(&resolved)
);
self.apply_patches(resolved)
} else {
log::info!(
"VFS: Skipping Create/Write for {} — \
parent no longer exists",
self.display_path(&path),
);
Vec::new()
}
}
}
}
VfsEvent::Remove(path) => {
let parent = path.parent().unwrap();
if parent.exists() {
log::info!("VFS: Remove for {}", self.display_path(&path));
self.apply_patches(path)
} else {
log::info!(
"VFS: Skipping remove event for {} — parent no longer exists",
self.display_path(&path),
);
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");
}
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) -> Vec<AppliedPatchSet> {
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
};
let mut all_patches = Vec::new();
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.",
self.display_path(&path)
);
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
);
all_patches.extend(patches);
}
} else {
log::info!(
"VFS recovery: path {} confirmed removed from disk.",
self.display_path(&path)
);
}
}
all_patches
}
/// Re-snapshots the entire project from the real filesystem and patches
/// the in-memory tree to correct any drift from missed VFS events.
/// Called when the file watcher reports `RescanRequired`.
fn reconcile_tree(&self) {
use crate::snapshot::InstanceContext;
let start = Instant::now();
let mut instance_context = InstanceContext::new();
instance_context.sync_scripts_only = self.sync_scripts_only;
let snapshot =
match snapshot_from_vfs(&instance_context, &self.vfs, &self.project_file_path) {
Ok(s) => s,
Err(e) => {
log::error!("Tree reconciliation snapshot error: {:?}", e);
return;
}
};
let mut tree = self.tree.lock().unwrap();
let root_id = tree.get_root_id();
let patch_set = compute_patch_set(snapshot, &tree, root_id);
// Only reconcile structural drift (added/removed instances).
// Property updates from compute_patch_set can produce false positives
// (e.g., Ref properties differ between snapshot IDs and tree IDs).
// Property changes are already handled correctly by individual VFS events.
if patch_set.removed_instances.is_empty() && patch_set.added_instances.is_empty() {
log::info!(
"Tree reconciliation: no drift detected ({:.1?})",
start.elapsed()
);
return;
}
let added = patch_set.added_instances.len();
let removed = patch_set.removed_instances.len();
// Strip property updates to avoid false-positive patches
let structural_patch = PatchSet {
added_instances: patch_set.added_instances,
removed_instances: patch_set.removed_instances,
updated_instances: Vec::new(),
stage_ids: HashSet::new(),
stage_paths: Vec::new(),
};
let applied = apply_patch_set(&mut tree, structural_patch);
drop(tree);
self.message_queue.push_messages(&[applied]);
log::info!(
"Tree reconciliation: corrected {} added, {} removed ({:.1?})",
added,
removed,
start.elapsed()
);
}
fn handle_tree_event(&self, mut 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();
// NOTE: We do NOT delete files from disk here. The API handler
// (handle_api_write → remove_instance_at_path) already deleted
// the files before sending this PatchSet. Re-deleting would
// destroy any new file created at the same path — e.g., when an
// instance is renamed from "joe_test" to "joe/test", the new slug
// is also "joe_test", so the API creates a new file at the same
// path that the removal just deleted. If we deleted again here,
// the new file would be destroyed.
//
// The PatchSet removal only needs to update the DOM tree (handled
// by apply_patch_set below).
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) => {
log::info!(
"Two-way sync: Removing instance {:?} from tree (path: {})",
id,
self.display_path(path)
);
}
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);
}
}
// Dedup suffix cleanup: after removals, check if any dedup
// groups need suffix renames (base-name promotion, group-to-1).
// Must run BEFORE apply_patch_set removes the instances from
// the tree, because we need parent/sibling relationships.
//
// Build a set of ALL removed Refs so the sibling enumeration
// skips co-removed instances (Fix 2: batch-removal awareness).
let removed_set: std::collections::HashSet<Ref> =
patch_set.removed_instances.iter().copied().collect();
// Collect metadata updates for survivors whose filesystem path
// changed due to dedup cleanup renames (Fix 1: metadata drift).
let mut dedup_metadata_updates: Vec<(Ref, PathBuf)> = Vec::new();
for &removed_id in &patch_set.removed_instances {
let (parent_ref, removed_fs_name, removed_file_dir) = {
let Some(inst) = tree.get_instance(removed_id) else {
continue;
};
let Some(source) = &inst.metadata().instigating_source else {
continue;
};
if matches!(source, InstigatingSource::ProjectNode { .. }) {
continue;
}
let parent = inst.parent();
let fs_name = tree.filesystem_name_for(removed_id);
// Derive the directory containing this file from its own
// instigating source path. This works even when the parent
// is a ProjectNode (where instigating_source is not a Path).
let file_dir = match source {
InstigatingSource::Path(p) => {
let fname = p.file_name().and_then(|f| f.to_str()).unwrap_or("");
if fname.starts_with("init.") {
// Directory-format: path is dir/init.luau,
// the containing dir is the grandparent
p.parent().and_then(|d| d.parent()).map(|d| d.to_path_buf())
} else {
p.parent().map(|d| d.to_path_buf())
}
}
_ => None,
};
(parent, fs_name, file_dir)
};
if parent_ref.is_none() {
continue;
}
// Parse the dedup suffix from the removed instance's FS name
let removed_stem = removed_fs_name
.split('.')
.next()
.unwrap_or(&removed_fs_name);
let (base_stem, _) = match parse_dedup_suffix(removed_stem) {
Some((base, n)) => (base.to_string(), Some(n)),
None => (removed_stem.to_string(), None),
};
// Find siblings that share the same dedup base stem AND extension.
// Matching by stem alone would incorrectly group across different
// middleware types (e.g., Foo.server.luau and Foo.luau have the
// same stem "Foo" but different dedup keys).
let removed_extension =
removed_fs_name.find('.').map(|i| &removed_fs_name[i + 1..]);
let Some(parent_inst) = tree.get_instance(parent_ref) else {
continue;
};
let mut remaining_stems: Vec<String> = Vec::new();
let deleted_was_base = parse_dedup_suffix(removed_stem).is_none();
for &sibling_ref in parent_inst.children() {
// Skip ALL instances being removed in this patch, not just
// the current one. This prevents co-removed siblings from
// inflating the remaining count and defeating cleanup rules.
if removed_set.contains(&sibling_ref) {
continue;
}
let sibling_fs = tree.filesystem_name_for(sibling_ref);
let sibling_stem = sibling_fs.split('.').next().unwrap_or(&sibling_fs);
let sibling_ext = sibling_fs.find('.').map(|i| &sibling_fs[i + 1..]);
// Only group siblings with the same extension (same dedup key space)
if sibling_ext != removed_extension {
continue;
}
let sibling_base = match parse_dedup_suffix(sibling_stem) {
Some((b, _)) => b,
None => sibling_stem,
};
if sibling_base.eq_ignore_ascii_case(&base_stem) {
remaining_stems.push(sibling_stem.to_string());
}
}
if remaining_stems.is_empty() {
continue;
}
let extension = removed_extension;
// Get the parent directory path (derived from the removed
// instance's own path, not the parent's instigating source,
// because the parent may be a ProjectNode).
let Some(parent_dir) = removed_file_dir.clone() else {
continue;
};
let action = compute_cleanup_action(
&base_stem,
extension,
&remaining_stems,
deleted_was_base,
&parent_dir,
);
match action {
DedupCleanupAction::None => {}
DedupCleanupAction::RemoveSuffix { from, to }
| DedupCleanupAction::PromoteLowest { from, to } => {
log::info!(
"Dedup cleanup: renaming {} -> {}",
self.display_path(&from),
self.display_path(&to),
);
self.suppress_path_any(&from);
self.suppress_path(&to);
if let Err(e) = fs::rename(&from, &to) {
log::warn!(
"Dedup cleanup rename failed: {} -> {}: {}",
from.display(),
to.display(),
e
);
self.unsuppress_path(&to);
} else {
// Also rename the adjacent meta file if it exists
// (standalone files only; directory-format instances
// have init.meta.json5 inside the directory which
// moves automatically with the dir rename).
if from.is_file() || !from.exists() {
if let (Some(from_parent), Some(from_name), Some(to_name)) = (
from.parent(),
from.file_stem().and_then(|s| s.to_str()),
to.file_stem().and_then(|s| s.to_str()),
) {
let from_base = strip_script_suffix(from_name);
let to_base = strip_script_suffix(to_name);
let old_meta =
from_parent.join(format!("{}.meta.json5", from_base));
if old_meta.exists() {
let new_meta =
from_parent.join(format!("{}.meta.json5", to_base));
self.suppress_path_any(&old_meta);
self.suppress_path(&new_meta);
if fs::rename(&old_meta, &new_meta).is_err() {
self.unsuppress_path_any(&old_meta);
self.unsuppress_path(&new_meta);
}
}
}
}
// Update ref paths if the renamed instance has refs
let old_ref_segment =
from.file_name().and_then(|f| f.to_str()).unwrap_or("");
let new_ref_segment =
to.file_name().and_then(|f| f.to_str()).unwrap_or("");
if old_ref_segment != new_ref_segment {
let parent_path =
crate::ref_target_path_from_tree(&tree, parent_ref);
let old_prefix = format!("{}/{}", parent_path, old_ref_segment);
let new_prefix = format!("{}/{}", parent_path, new_ref_segment);
self.update_ref_paths_after_rename(&old_prefix, &new_prefix, &tree);
}
// Fix 1: Update the renamed survivor's in-memory
// metadata so InstigatingSource::Path and
// relevant_paths point to the new filesystem path.
let old_from_name =
from.file_name().and_then(|f| f.to_str()).unwrap_or("");
for &sibling_ref in parent_inst.children() {
if removed_set.contains(&sibling_ref) {
continue;
}
let sibling_fs = tree.filesystem_name_for(sibling_ref);
if sibling_fs == old_from_name {
// This is the survivor being renamed.
// Compute its new instigating source path.
if let Some(meta) = tree.get_metadata(sibling_ref) {
if let Some(InstigatingSource::Path(old_path)) =
&meta.instigating_source
{
let new_source = if old_path
.file_name()
.and_then(|f| f.to_str())
.map(|f| f.starts_with("init."))
.unwrap_or(false)
{
// Directory-format: old source
// is dir/init.luau; the dir was
// renamed so update the dir
// portion.
let init_name =
old_path.file_name().unwrap().to_str().unwrap();
to.join(init_name)
} else {
// Standalone file: new source
// IS the `to` path.
to.clone()
};
dedup_metadata_updates.push((sibling_ref, new_source));
}
}
break;
}
}
}
}
}
}
// 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();
// Paths to stage via git add after all writes complete.
// Starts with pre-resolved paths from api.rs, then Source writes are appended.
let mut pending_stage_paths = std::mem::take(&mut patch_set.stage_paths);
for update in &patch_set.updated_instances {
let id = update.id;
// Capture the old path BEFORE rename for Rojo_Ref_* path updates.
// Must be computed before the `tree.get_instance(id)` borrow.
let old_ref_path =
if update.changed_name.is_some() || update.changed_class_name.is_some() {
Some(crate::ref_target_path_from_tree(&tree, id))
} else {
None
};
// The new filesystem name segment after a rename. Set during
// rename handling and used by the ref path update code to build
// the correct filesystem-name-based ref path.
let mut new_ref_segment: Option<String> = None;
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() {
let dir_name = dir_path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("");
// Slugify the new name for filesystem safety
let slugified_new_name =
if name_needs_slugify(new_name) {
slugify_name(new_name)
} else {
new_name.to_string()
};
// Directory renames: fs::rename fails safely
// on all platforms if the target is a non-empty
// directory, so no dedup is needed here (unlike
// the file rename path below, where Unix
// fs::rename silently overwrites).
let new_dir_path =
grandparent.join(&slugified_new_name);
// Track the effective directory path after any rename
let effective_dir_path;
if new_dir_path != dir_path {
log::info!(
"Two-way sync: Renaming directory {} -> {}",
self.display_path(dir_path),
self.display_path(&new_dir_path)
);
self.suppress_path_any(dir_path);
self.suppress_path(&new_dir_path);
if let Err(err) =
fs::rename(dir_path, &new_dir_path)
{
self.unsuppress_path_any(dir_path);
self.unsuppress_path(&new_dir_path);
log::error!(
"Failed to rename directory {:?} to {:?}: {}",
dir_path,
new_dir_path,
err
);
effective_dir_path = dir_path.to_path_buf();
} else {
// The init file moved with the directory.
overridden_source_path =
Some(new_dir_path.join(file_name));
new_ref_segment =
Some(slugified_new_name.clone());
if patch_set.stage_ids.contains(&id) {
let old_init = dir_path.join(file_name);
pending_stage_paths
.retain(|p| *p != old_init);
pending_stage_paths
.push(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",
slugified_new_name
));
self.suppress_path_any(&old_meta);
self.suppress_path(&new_meta);
if fs::rename(&old_meta, &new_meta)
.is_err()
{
self.unsuppress_path_any(&old_meta);
self.unsuppress_path(&new_meta);
}
}
effective_dir_path = new_dir_path.clone();
}
} else {
effective_dir_path = dir_path.to_path_buf();
}
// Always update the meta name field when the
// instance name changed, even if the directory
// path didn't change (e.g. "Foo/Bar" → "Foo|Bar"
// both slugify to "Foo_Bar").
let init_meta =
effective_dir_path.join("init.meta.json5");
if slugified_new_name != *new_name {
self.upsert_meta_name_field(
&init_meta, new_name,
);
} else {
self.remove_meta_name_field(&init_meta);
}
}
} else if let Some(parent) = path.parent() {
// Derive the suffix from the existing filename
// rather than using replacen with the instance
// name, which may differ from the filesystem
// name (e.g., slugified names).
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", ".model",
];
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 slugified_new_name = if name_needs_slugify(new_name)
{
slugify_name(new_name)
} else {
new_name.to_string()
};
// Guard against rename collision: if the target
// path already exists and isn't our own file,
// deduplicate the name against siblings.
let deduped_new_name;
{
let candidate_file = if extension.is_empty() {
format!(
"{}{}",
slugified_new_name, script_suffix
)
} else {
format!(
"{}{}.{}",
slugified_new_name,
script_suffix,
extension
)
};
let candidate = parent.join(&candidate_file);
if candidate != *path && candidate.exists() {
let mut taken: std::collections::HashSet<
String,
> = std::collections::HashSet::new();
if let Ok(entries) = fs::read_dir(parent) {
for entry in entries.flatten() {
let ep = entry.path();
let slug = if ep.is_dir() {
ep.file_name()
.and_then(|f| f.to_str())
.unwrap_or("")
.to_lowercase()
} else {
let s = ep
.file_stem()
.and_then(|f| f.to_str())
.unwrap_or("");
strip_script_suffix(s)
.to_lowercase()
};
taken.insert(slug);
}
}
// Free the slot we're vacating
taken.remove(&old_base.to_lowercase());
deduped_new_name = deduplicate_name(
&slugified_new_name,
&taken,
);
} else {
deduped_new_name = slugified_new_name.clone();
}
}
let new_file_name = if extension.is_empty() {
format!("{}{}", deduped_new_name, script_suffix)
} else {
format!(
"{}{}.{}",
deduped_new_name, script_suffix, extension
)
};
let new_path = parent.join(&new_file_name);
// Track the effective meta base name after any rename
let effective_meta_base: &str;
if new_path != *path {
log::info!(
"Two-way sync: Renaming {} -> {}",
self.display_path(path),
self.display_path(&new_path)
);
self.suppress_path_any(path);
self.suppress_path(&new_path);
if let Err(err) = fs::rename(path, &new_path) {
self.unsuppress_path_any(path);
self.unsuppress_path(&new_path);
log::error!(
"Failed to rename {:?} to {:?}: {}",
path,
new_path,
err
);
effective_meta_base = old_base;
} else {
overridden_source_path = Some(new_path.clone());
new_ref_segment = Some(new_file_name.clone());
if patch_set.stage_ids.contains(&id) {
pending_stage_paths.retain(|p| *p != *path);
pending_stage_paths.push(new_path.clone());
}
let old_meta = parent
.join(format!("{}.meta.json5", old_base));
let new_meta = parent.join(format!(
"{}.meta.json5",
deduped_new_name
));
if old_meta.exists() {
self.suppress_path_any(&old_meta);
self.suppress_path(&new_meta);
if fs::rename(&old_meta, &new_meta).is_err()
{
self.unsuppress_path_any(&old_meta);
self.unsuppress_path(&new_meta);
}
}
effective_meta_base = &deduped_new_name;
}
} else {
effective_meta_base = old_base;
}
// Always update the name field when the
// instance name changed, even if the path didn't
// change (e.g. "Foo/Bar" → "Foo|Bar" both slugify
// to "Foo_Bar"). Only slugification requires a
// meta name -- dedup suffixes (~N) are stripped
// automatically by forward sync.
//
// For .model.json5/.model.json files, the name
// field lives INSIDE the model file, not in
// adjacent .meta.json5.
if script_suffix == ".model" {
let model_file = overridden_source_path
.as_deref()
.unwrap_or(path.as_path());
if slugified_new_name != *new_name {
self.upsert_model_name_field(
model_file, new_name,
);
} else {
self.remove_model_name_field(model_file);
}
} else {
let current_meta = parent.join(format!(
"{}.meta.json5",
effective_meta_base
));
if slugified_new_name != *new_name {
self.upsert_meta_name_field(
&current_meta,
new_name,
);
} else {
self.remove_meta_name_field(&current_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 base name from the filesystem
// path, not instance.name() (which may
// differ from the slugified filename).
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,
self.display_path(&actual_file),
self.display_path(&new_path)
);
self.suppress_path_any(&actual_file);
self.suppress_path(&new_path);
if let Err(err) =
fs::rename(&actual_file, &new_path)
{
self.unsuppress_path_any(&actual_file);
self.unsuppress_path(&new_path);
log::error!(
"Failed to rename {:?} to {:?}: {}",
actual_file,
new_path,
err
);
} else {
overridden_source_path = Some(new_path.clone());
if patch_set.stage_ids.contains(&id) {
pending_stage_paths
.retain(|p| *p != actual_file);
pending_stage_paths.push(new_path.clone());
}
// For standalone files, the ref path
// segment changes with the extension.
// For init files, the directory name
// (ref segment) is unchanged.
if !is_init {
new_ref_segment =
Some(new_file_name.clone());
}
}
}
} else {
log::warn!(
"Cannot change ClassName for directory {} \
— no init file found inside",
self.display_path(path)
);
}
} 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 {
if let Ok(existing) = fs::read(write_path) {
if existing == value.as_bytes() {
log::debug!(
"Two-way sync: Skipping unchanged Source for {}",
self.display_path(write_path)
);
if patch_set.stage_ids.contains(&id) {
pending_stage_paths.push(write_path.clone());
}
continue;
}
}
log::info!(
"Two-way sync: Writing Source to {}",
self.display_path(write_path)
);
self.suppress_path(write_path);
if let Err(err) = fs::write(write_path, value) {
self.unsuppress_path(write_path);
log::error!(
"Failed to write Source to {:?} for instance {:?}: {}",
write_path,
id,
err
);
} else if patch_set.stage_ids.contains(&id) {
pending_stage_paths.push(write_path.clone());
}
} 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);
}
// After rename, update any Rojo_Ref_* paths that referenced the
// old path of this instance (or its descendants). Done outside the
// `if let Some(instance)` block to avoid borrow conflicts.
//
// NOTE: The tree name hasn't been updated yet (apply_patch_set
// runs after this loop), so we can't use full_path_of for the
// new path. Construct it by replacing the last path segment
// with the NEW filesystem name (set during rename handling).
if let Some(ref old_ref_path) = old_ref_path {
if let Some(ref segment) = new_ref_segment {
// Use the filesystem name computed during rename handling
let segments: Vec<&str> = old_ref_path.split('/').collect();
let new_ref_path = if segments.len() > 1 {
let parent = segments[..segments.len() - 1].join("/");
format!("{}/{}", parent, segment)
} else {
segment.clone()
};
if *old_ref_path != new_ref_path {
self.update_ref_paths_after_rename(old_ref_path, &new_ref_path, &tree);
}
} else if update.changed_name.is_some() || update.changed_class_name.is_some() {
// Rename or class change was requested but no filesystem
// rename happened (e.g., ProjectNode, init-file class
// change where directory name stays the same). No ref
// path update needed.
log::trace!(
"Skipping ref path update for {:?}: no filesystem rename",
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.into_iter().chain(dedup_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);
}
}
// Consolidated git staging: one git_add call for all paths
// (pre-resolved from api.rs + Source writes from this function).
if !pending_stage_paths.is_empty() {
if let Some(ref repo_root) = self.git_repo_root {
crate::git::git_add(repo_root, &pending_stage_paths);
}
}
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,
project_root: &Path,
) -> Option<ComputeResult> {
// Use rel_path(p, project_root) inline for log display.
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 {:?}{}",
rel_path(path, project_root),
id,
if is_init_file {
format!(
" (init file, will snapshot parent dir {})",
rel_path(snapshot_path, project_root)
)
} 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 {}",
rel_path(snapshot_path, project_root)
);
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.",
rel_path(path, project_root)
);
let snapshot =
match snapshot_from_vfs(&metadata.context, vfs, snapshot_path) {
Ok(snapshot) => snapshot,
Err(err) => {
log::error!(
"Recovery snapshot error for {}: {:?}",
rel_path(snapshot_path, project_root),
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.",
rel_path(path, project_root),
rel_path(snapshot_path, project_root)
);
let snapshot =
match snapshot_from_vfs(&metadata.context, vfs, snapshot_path) {
Ok(snapshot) => snapshot,
Err(err) => {
log::error!(
"Directory re-snapshot error for {}: {:?}",
rel_path(snapshot_path, project_root),
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,
rel_path(path, project_root)
);
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 {}: {:?}",
rel_path(path, project_root),
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,
rel_path(path, project_root)
);
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.plugin.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")),
]
}
}