#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using T3.Core.IO;
using T3.Core.Logging;
using T3.Core.Resource.Assets;
using T3.Core.Settings;
using T3.Core.Utils;
namespace T3.Core.Resource;
///
/// Handles events for loaded resources files and forwards them to
///
public sealed class ResourceFileWatcher : IDisposable
{
public ResourceFileWatcher(string watchedFolder)
{
Directory.CreateDirectory(watchedFolder);
_watchedDirectory = watchedFolder;
// Watch unconditionally (not only while file hooks exist): external changes to the
// assets folder must keep the AssetRegistry and asset library in sync.
_fsWatcher = CreateFsWatcher();
ResourcePackageManager.RegisterWatcher(this);
}
public void Dispose()
{
DisposeFileWatcher(ref _fsWatcher);
_fileChangeActions.Clear();
ResourcePackageManager.UnregisterWatcher(this);
}
internal void AddFileHook(string filepath, FileWatcherAction action)
{
if (string.IsNullOrWhiteSpace(filepath))
return;
ArgumentNullException.ThrowIfNull(action);
if (!FilepathStartsWith(filepath,_watchedDirectory))
{
Log.Error($"Cannot watch file outside of watched directory: \"{filepath}\" is not in \"{_watchedDirectory}\"");
return;
}
if (!_fileChangeActions.TryGetValue(filepath, out var existingActions))
{
existingActions = new List();
_fileChangeActions.TryAdd(filepath, existingActions);
}
existingActions.Add(action);
}
private bool FilepathStartsWith(string path, string start)
{
if (path.Length < start.Length)
return false;
for (var index = 0; index < start.Length; index++)
{
var cA = path[index];
var cB = start[index];
if (cA == cB)
continue;
if ((cA == '/' || cA == '\\') && (cB == '/' || cB == '\\'))
continue;
return false;
}
return true;
}
internal void RemoveFileHook(string absolutePathFs, FileWatcherAction onResourceChanged)
{
if (!_fileChangeActions.TryGetValue(absolutePathFs, out var actions))
return;
actions.Remove(onResourceChanged);
if (actions.Count != 0)
return;
_fileChangeActions.Remove(absolutePathFs, out _);
}
internal void RaiseQueuedFileChanges()
{
var currentTime = DateTime.UtcNow.Ticks;
const long thresholdTicks = 394 * TimeSpan.TicksPerMillisecond;
lock (_eventLock)
{
if (_newFileEvents.Count == 0)
return;
var fileEvents = _newFileEvents.OrderBy(x => x.Value.TimeTicks).ToArray();
FileKey previous = default;
foreach (var (fileKey, details) in fileEvents)
{
if (currentTime - details.TimeTicks < thresholdTicks)
break;
_newFileEvents.Remove(fileKey);
if (fileKey == previous)
continue;
previous = fileKey;
var path = details.Args.FullPath.ToForwardSlashes();
// 1. Synchronize the AssetRegistry
if (fileKey.IsRename && details.Args is RenamedEventArgs renamedArgs)
{
var renamedArgsOldFullPath = renamedArgs.OldFullPath.ToForwardSlashes();
if (_fileChangeActions.Remove(renamedArgsOldFullPath, out var actions1))
{
if (_fileChangeActions.TryGetValue(path, out var previousActions))
{
previousActions.AddRange(actions1);
}
else
{
_fileChangeActions.TryAdd(path, actions1);
}
}
FileRenamed?.Invoke(renamedArgsOldFullPath, renamedArgs.FullPath);
FileStateChangeCounter++;
}
else if (fileKey.ChangeType == WatcherChangeTypes.Deleted)
{
FileDeleted?.Invoke(this, path);
FileStateChangeCounter++;
}
else if (fileKey.ChangeType == WatcherChangeTypes.Created)
{
// Only register if it's a file we care about
if (!FileLocations.IgnoredFiles.Contains(Path.GetFileName(path)))
{
// This will invoke an AssetRegistry Update for editable projects
FileCreated?.Invoke(this, path);
}
FileStateChangeCounter++;
}
// 2. Dispatch hooks to Resources/Operators
if (_fileChangeActions.TryGetValue(path, out var actions))
{
foreach (var action in actions)
{
_queuedActions.Enqueue(new FileWatchQueuedAction(details.Args.ChangeType, path, action));
}
FileStateChangeCounter++;
}
}
}
// Process the actions outside the lock
while (_queuedActions.TryDequeue(out var queuedAction))
{
try
{
queuedAction.Action(queuedAction.ChangeType, queuedAction.FullPath);
}
catch (Exception exception)
{
Log.Error($"Error in file change action: {exception}");
}
}
}
private void OnFileCreated(object sender, FileSystemEventArgs e)
{
if(CoreSettings.Config.LogFileEvents)
Log.Debug($"FileEvent(create): {e.FullPath}");
// FileCreated is raised from the debounced queue in RaiseQueuedFileChanges,
// on the main thread and with IgnoredFiles filtered out.
OnFileChanged(this, e);
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
if(CoreSettings.Config.LogFileEvents)
Log.Debug($"FileEvent(change) [{e.ChangeType}] {e.FullPath}");
e.FullPath.ToForwardSlashesUnsafe();
var isRenamed = false;
if (e is RenamedEventArgs renamedArgs)
{
renamedArgs.OldFullPath.ToForwardSlashesUnsafe();
isRenamed = true;
}
var fileKey = new FileKey(e.FullPath, e.ChangeType, isRenamed);
lock (_eventLock)
{
_newFileEvents[fileKey] = new FileWatchDetails(DateTime.UtcNow.Ticks, e);
FileStateChangeCounter++;
}
}
private void OnError(object sender, ErrorEventArgs e)
{
if(CoreSettings.Config.LogFileEvents)
Log.Error($"FileEvent(error): {e.GetException()}");
DisposeFileWatcher(ref _fsWatcher);
_fsWatcher = CreateFsWatcher();
}
private FileSystemWatcher CreateFsWatcher()
{
var fsWatcher = new FileSystemWatcher(_watchedDirectory)
{
IncludeSubdirectories = true,
EnableRaisingEvents = true,
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.DirectoryName
};
fsWatcher.Changed += OnFileChanged;
fsWatcher.Renamed += OnFileChanged;
fsWatcher.Created += OnFileCreated;
fsWatcher.Deleted += OnFileDeleted;
fsWatcher.Error += OnError;
return fsWatcher;
}
private void OnFileDeleted(object sender, FileSystemEventArgs e)
{
OnFileChanged(sender, e);
if(CoreSettings.Config.LogFileEvents)
Log.Debug($"FileEvent(delete): {e.FullPath}");
}
private static void DisposeFileWatcher(ref FileSystemWatcher? watcher)
{
if (watcher == null)
return;
watcher.EnableRaisingEvents = false;
watcher.Dispose();
watcher = null;
}
private record struct FileKey(string Path, WatcherChangeTypes ChangeType, bool IsRename);
private record struct FileWatchDetails(long TimeTicks, FileSystemEventArgs Args);
private record struct FileWatchQueuedAction(WatcherChangeTypes ChangeType, string FullPath, FileWatcherAction Action);
private readonly Dictionary _newFileEvents = new();
private readonly Lock _eventLock = new();
private readonly string _watchedDirectory;
private FileSystemWatcher? _fsWatcher;
private readonly ConcurrentDictionary> _fileChangeActions = new();
private readonly Queue _queuedActions = new();
public event EventHandler? FileCreated;
public event EventHandler? FileDeleted;
public event Action? FileRenamed; // OldPath, NewPath
///
/// This is incremented on every file change event and can be used for cache invalidation (e.g. for complex FileLists)
///
public static int FileStateChangeCounter { get; set; }
}
internal delegate void FileWatcherAction(WatcherChangeTypes changeTypes, string absolutePath);
internal static class WatcherChangeTypesExtensions
{
public static bool WasDeleted(this WatcherChangeTypes changeTypes)
{
return (changeTypes & WatcherChangeTypes.Deleted) == WatcherChangeTypes.Deleted;
}
public static bool WasMoved(this WatcherChangeTypes changeTypes)
{
return (changeTypes & WatcherChangeTypes.Renamed) == WatcherChangeTypes.Renamed;
}
public static bool WasCreated(this WatcherChangeTypes changeTypes)
{
return (changeTypes & WatcherChangeTypes.Created) == WatcherChangeTypes.Created;
}
public static bool WasChanged(this WatcherChangeTypes changeTypes)
{
return (changeTypes & WatcherChangeTypes.Changed) == WatcherChangeTypes.Changed;
}
}