From 2b76eb96c0b2c85095adca29c826d902b1be04c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20=C5=9Acis=C5=82owicz?= Date: Sat, 20 Apr 2024 14:55:09 +0200 Subject: [PATCH] Visual Studio support (#4) * Handle file changes in Visual Studio * Implemented simple in-memory cache * Improved complex event handling in `FileWatcher` * Relaxed duplicate event detection criteria I.e., disabled duplicate detection for events other than `Changed`. * Refactored `OnFileSystemEvent` There's no need to pass a `path` there, as it's already available via `args`. * Added support for tracking complex changes on ReFS --------- Co-authored-by: Kir-Antipov Co-authored-by: zeryk24 <61562176+zeryk24@users.noreply.github.com> Co-authored-by: MinikPLayer <39161575+MinikPLayer@users.noreply.github.com> --- src/HotAvalonia/Collections/MemoryCache.cs | 123 ++++++++ src/HotAvalonia/IO/FileWatcher.cs | 332 ++++++++++++++------- 2 files changed, 342 insertions(+), 113 deletions(-) create mode 100644 src/HotAvalonia/Collections/MemoryCache.cs diff --git a/src/HotAvalonia/Collections/MemoryCache.cs b/src/HotAvalonia/Collections/MemoryCache.cs new file mode 100644 index 0000000..366af83 --- /dev/null +++ b/src/HotAvalonia/Collections/MemoryCache.cs @@ -0,0 +1,123 @@ +using System.Collections; +using HotAvalonia.Helpers; + +namespace HotAvalonia.Collections; + +/// +/// Represents a memory cache that stores items of type . +/// +/// The type of items to be stored in the cache. +internal sealed class MemoryCache : ICollection, IReadOnlyCollection +{ + /// + /// The list of entries stored in the memory cache. + /// + private readonly List _entries; + + /// + /// The lifespan of items in the cache. + /// + private readonly double _lifespan; + + /// + /// Initializes a new instance of the class with the specified lifespan. + /// + /// The lifespan of items in the cache. + public MemoryCache(TimeSpan lifespan) + { + _entries = new(); + _lifespan = lifespan.TotalMilliseconds; + } + + /// + /// Gets the lifespan of items in the cache. + /// + public TimeSpan Lifespan => TimeSpan.FromMilliseconds(_lifespan); + + /// + public int Count + { + get + { + RemoveStale(); + return _entries.Count; + } + } + + /// + bool ICollection.IsReadOnly => false; + + /// + public void Add(T item) => _entries.Add(new(item)); + + /// + public bool Remove(T item) => _entries.RemoveAll(x => EqualityComparer.Default.Equals(item, x.Value)) != 0; + + /// + /// Removes stale entries from the cache based on their timestamp. + /// + private void RemoveStale() + { + long currentTimestamp = StopwatchHelper.GetTimestamp(); + _entries.RemoveAll(x => StopwatchHelper.GetElapsedTime(x.Timestamp, currentTimestamp).TotalMilliseconds > _lifespan); + } + + /// + public void Clear() => _entries.Clear(); + + /// + public bool Contains(T item) => _entries.Any(x => EqualityComparer.Default.Equals(item, x.Value)); + + /// + public void CopyTo(T[] array, int arrayIndex) + { + RemoveStale(); + _entries.ConvertAll(static x => x.Value).CopyTo(array, arrayIndex); + } + + /// + public IEnumerator GetEnumerator() + { + RemoveStale(); + return _entries.Select(static x => x.Value).GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + /// Represents an entry in the cache containing the value and its timestamp. + /// + private sealed class Entry + { + /// + /// Gets the value stored in the cache entry. + /// + public T Value { get; } + + /// + /// Gets the timestamp when the cache entry was added. + /// + public long Timestamp { get; } + + /// + /// Initializes a new instance of the class with the specified value. + /// + /// The value to be stored in the cache entry. + public Entry(T value) + : this(value, StopwatchHelper.GetTimestamp()) + { + } + + /// + /// Initializes a new instance of the class with the specified value and timestamp. + /// + /// The value to be stored in the cache entry. + /// The timestamp when the cache entry was added. + public Entry(T value, long timestamp) + { + Value = value; + Timestamp = timestamp; + } + } +} diff --git a/src/HotAvalonia/IO/FileWatcher.cs b/src/HotAvalonia/IO/FileWatcher.cs index 14ef091..4f2993c 100644 --- a/src/HotAvalonia/IO/FileWatcher.cs +++ b/src/HotAvalonia/IO/FileWatcher.cs @@ -1,3 +1,4 @@ +using HotAvalonia.Collections; using HotAvalonia.Helpers; namespace HotAvalonia.IO; @@ -7,33 +8,15 @@ namespace HotAvalonia.IO; /// internal sealed class FileWatcher : IDisposable { - /// - /// The minimum time difference required to consider a write operation as unique. - /// - /// - /// https://en.wikipedia.org/wiki/Mental_chronometry#Measurement_and_mathematical_descriptions - /// - private const double MinWriteTimeDifference = 150; - - /// - /// The time duration for which create and delete events are buffered before being processed. - /// - private const double EventBufferLifetime = 100; - /// /// The set of tracked file paths. /// private readonly HashSet _files; /// - /// The last write times for the tracked files. + /// The cache of filesystem events. /// - private readonly Dictionary _lastWriteTimes; - - /// - /// The list of buffered create and delete events awaiting processing. - /// - private readonly List<(FileSystemEventArgs Event, long Timestamp)> _eventBuffer; + private readonly MemoryCache _eventCache; /// /// The object used for locking in thread-safe operations. @@ -51,14 +34,17 @@ internal sealed class FileWatcher : IDisposable /// The root directory to be watched. public FileWatcher(string rootPath) { + // The minimum time difference required to consider a write operation as unique. + // See: https://en.wikipedia.org/wiki/Mental_chronometry#Measurement_and_mathematical_descriptions + const double MinWriteTimeDifference = 150; + _ = rootPath ?? throw new ArgumentNullException(nameof(rootPath)); _ = Directory.Exists(rootPath) ? rootPath : throw new DirectoryNotFoundException(rootPath); DirectoryName = rootPath; _systemWatcher = CreateFileSystemWatcher(rootPath); _files = new(FileHelper.FileNameComparer); - _lastWriteTimes = new(FileHelper.FileNameComparer); - _eventBuffer = new(); + _eventCache = new(TimeSpan.FromMilliseconds(MinWriteTimeDifference)); _lock = new(); } @@ -113,19 +99,6 @@ public IEnumerable FileNames } } - /// - /// The sequence of buffered create and delete events awaiting processing. - /// - private IEnumerable BufferedEvents - { - get - { - CleanupEventBuffer(); - - return _eventBuffer.Select(static x => x.Event); - } - } - /// /// Starts watching a specified file. /// @@ -161,47 +134,27 @@ public void Unwatch(string fileName) /// The source of the event. /// The event arguments containing information about the file change. private void OnCreatedOrDeleted(object sender, FileSystemEventArgs args) - { - StringComparer fileNameComparer = FileHelper.FileNameComparer; - WatcherChangeTypes oppositeChangeType = args.ChangeType is WatcherChangeTypes.Created ? WatcherChangeTypes.Deleted : WatcherChangeTypes.Created; - string fileName = Path.GetFileName(args.FullPath); - string? newFullPath = null; - string? oldFullPath = null; - - lock (_lock) - { - FileSystemEventArgs? bufferedArgs = BufferedEvents.FirstOrDefault(x => x.ChangeType == oppositeChangeType && fileNameComparer.Equals(Path.GetFileName(x.FullPath), fileName)); - if (bufferedArgs is null) - { - BufferEvent(args); - return; - } - - (newFullPath, oldFullPath) = args.ChangeType is WatcherChangeTypes.Created - ? (args.FullPath, bufferedArgs.FullPath) - : (bufferedArgs.FullPath, args.FullPath); - } - - OnMoved(newFullPath, oldFullPath); - } + => OnFileSystemEvent(args); /// - /// Processes the event of a file change.. + /// Handles the event of a file change. /// /// The source of the event. /// The event arguments containing information about the file change. private void OnChanged(object sender, FileSystemEventArgs args) - { - string path = Path.GetFullPath(args.FullPath); + => OnFileSystemEvent(args, () => Changed?.Invoke(this, args)); - lock (_lock) + /// + /// Handles the event when a file is moved. + /// + /// The source of the event. + /// Event arguments containing the old and new name of the file. + private void OnMoved(object sender, MovedEventArgs args) + => OnFileSystemEvent(args, () => Moved?.Invoke(this, args), () => { - if (!IsWatchingFile(path) || !TryUpdateLastWriteTime(path)) - return; - } - - Changed?.Invoke(this, args); - } + _files.Remove(Path.GetFullPath(args.OldFullPath)); + _files.Add(Path.GetFullPath(args.FullPath)); + }); /// /// Handles the event when a file is renamed. @@ -209,89 +162,242 @@ private void OnChanged(object sender, FileSystemEventArgs args) /// The source of the event. /// Event arguments containing the old and new name of the file. private void OnRenamed(object sender, RenamedEventArgs args) - => OnMoved(args.FullPath, args.OldFullPath); + => OnMoved(sender, new(args.FullPath, args.OldFullPath)); /// - /// Processes the movement of a file and updates the internal list of files being watched. + /// Handles any error that occurs during file watching. /// - /// The new full path of the file. - /// The old full path of the file. - private void OnMoved(string newFullPath, string oldFullPath) - { - newFullPath = Path.GetFullPath(newFullPath); - oldFullPath = Path.GetFullPath(oldFullPath); + /// The source of the event. + /// The event arguments containing error details. + private void OnError(object sender, ErrorEventArgs args) + => Error?.Invoke(this, args); + /// + /// Handles a filesystem event, performing necessary actions based on the event type. + /// + /// The event arguments containing information about the filesystem operation. + /// The path of the file associated with the event. + /// A handler for the event. + /// An action to synchronize changes after processing the event. + private void OnFileSystemEvent(FileSystemEventArgs args, Action? handler = null, Action? sync = null) + { lock (_lock) { - if (!IsWatchingFile(oldFullPath) || !TryUpdateLastWriteTime(newFullPath)) + bool isFullPathWatched = IsWatchingFile(args.FullPath); + bool isOldFullPathWatched = args is MovedEventArgs moved && IsWatchingFile(moved.OldFullPath); + if (!isFullPathWatched && !isOldFullPathWatched) + { + _eventCache.Add(args); return; + } - _files.Remove(oldFullPath); - _lastWriteTimes.Remove(oldFullPath); - _files.Add(newFullPath); + if (IsDuplicateEvent(args)) + return; } - Moved?.Invoke(this, new(newFullPath, oldFullPath)); + if (!TryProcessComplexEvent(args)) + handler?.Invoke(); + + lock (_lock) + { + _eventCache.Add(args); + sync?.Invoke(); + } } /// - /// Handles any error that occurs during file watching. + /// Determines if a file is being watched. /// - /// The source of the event. - /// The event arguments containing error details. - private void OnError(object sender, ErrorEventArgs args) - => Error?.Invoke(this, args); + /// The name of the file to check. + /// true if the file is being watched; otherwise, false. + private bool IsWatchingFile(string fileName) + => _files.Contains(Path.GetFullPath(fileName)); /// - /// Adds an event to the internal buffer for further processing. + /// Checks if the given filesystem event is a duplicate of any previously cached event. /// - /// The event arguments to be buffered. - private void BufferEvent(FileSystemEventArgs args) + /// The event arguments containing information about the filesystem operation. + /// true if the event is a duplicate; otherwise, false. + private bool IsDuplicateEvent(FileSystemEventArgs args) { - long currentTimestamp = StopwatchHelper.GetTimestamp(); + // Currently, we only care about filtering duplicate change events: + // - They trigger most of the unnecessary work. + // - They are the most straightforward ones to detect. + // + // Since duplicates of other events don't cause as much harm, + // let's just skip them for now and return to this matter later, + // if it becomes a problem. + if (args.ChangeType is not WatcherChangeTypes.Changed) + return false; + + WatcherChangeTypes type = args.ChangeType; + string path = Path.GetFullPath(args.FullPath); + StringComparer fileNameComparer = FileHelper.FileNameComparer; - _eventBuffer.Add((args, currentTimestamp)); + return _eventCache.Any(x => x.ChangeType == type && fileNameComparer.Equals(Path.GetFullPath(x.FullPath), path)); } /// - /// Removes stale events from the internal buffer. + /// Tries to process complex filesystem events that combine multiple atomic operations. + /// + /// The event arguments containing information about the filesystem operation. + /// true if a complex event (change or move operation) was successfully processed; otherwise, false. + private bool TryProcessComplexEvent(FileSystemEventArgs args) + => TryProcessComplexChange_NTFS(args) + || TryProcessComplexChange_ReFS(args) + || TryProcessComplexMove(args); + + /// + /// Tries to process a complex move operation that involves copying a file to a new destination and then deleting the original. /// - private void CleanupEventBuffer() + /// + /// Such operation involves the following steps: + /// + /// + /// A file is created at a new location, effectively copying the original file (e.g., `Source.axaml` -> `Target.axaml`). + /// + /// + /// The original file is deleted (e.g., `Source.axaml` is deleted). + /// + /// + /// Instead of emitting a separate 'created' event for the new location, and a 'deleted' event for the original location, + /// this method consolidates them and emits a single 'moved' event. + /// + /// The event arguments containing information about the filesystem operation. + /// true if a complex move operation was successfully processed; otherwise, false. + private bool TryProcessComplexMove(FileSystemEventArgs args) { - long currentTimestamp = StopwatchHelper.GetTimestamp(); + if (args.ChangeType is not (WatcherChangeTypes.Created or WatcherChangeTypes.Deleted)) + return false; + + WatcherChangeTypes oppositeChangeType = args.ChangeType is WatcherChangeTypes.Created ? WatcherChangeTypes.Deleted : WatcherChangeTypes.Created; + string fileName = Path.GetFileName(args.FullPath); + StringComparer fileNameComparer = FileHelper.FileNameComparer; + string? newFullPath = null; + string? oldFullPath = null; + + lock (_lock) + { + FileSystemEventArgs? oppositeEvent = _eventCache.FirstOrDefault(x => x.ChangeType == oppositeChangeType && fileNameComparer.Equals(Path.GetFileName(x.FullPath), fileName)); + if (oppositeEvent is null) + return false; + + (newFullPath, oldFullPath) = args.ChangeType is WatcherChangeTypes.Created + ? (args.FullPath, oppositeEvent.FullPath) + : (oppositeEvent.FullPath, args.FullPath); + } - _eventBuffer.RemoveAll(x => StopwatchHelper.GetElapsedTime(x.Timestamp, currentTimestamp).TotalMilliseconds > EventBufferLifetime); + OnMoved(this, new(newFullPath, oldFullPath)); + return true; } /// - /// Attempts to update the last write time for the specified file and validates if it should trigger further actions. + /// Tries to process a complex file modification operation that is commonly performed by some IDEs, such as Visual Studio. /// - /// The name of the file to update. - /// - /// true if the last write time was successfully updated and meets the criteria for an action; - /// otherwise, false. - /// - private bool TryUpdateLastWriteTime(string fileName) + /// + /// Such operation involves several steps on NTFS drives: + /// + /// + /// A copy of the original file is created (e.g., `mgwudjxu.mzo-`). This file will temporarily store applied changes. + /// + /// + /// Changes are applied to the copy. + /// + /// + /// The original file is given a temporary and randomly assigned filename (e.g., `MainWindow.axaml-RF44d4e140.TMP`). + /// + /// + /// The edited copy is moved back to the original file's location (`mgwudjxu.mzo-` -> `MainWindow.axaml`). + /// + /// + /// If no errors occurred during the process, the original file is deleted (`MainWindow.axaml-RF44d4e140.TMP`). + /// + /// + /// In contrast to responding to each individual event, this method cumulatively processes them and emits a single "changed" event. + /// + /// The event arguments containing information about the filesystem operation. + /// true if a complex change operation was successfully processed; otherwise, false. + private bool TryProcessComplexChange_NTFS(FileSystemEventArgs args) { - DateTime newWriteTime = string.IsNullOrEmpty(fileName) ? default : File.GetLastWriteTime(fileName); + if (args.ChangeType is not WatcherChangeTypes.Deleted) + return false; - if (!_lastWriteTimes.TryGetValue(fileName, out DateTime lastWriteTime)) - lastWriteTime = default; + string path = Path.GetFullPath(args.FullPath); + StringComparer fileNameComparer = FileHelper.FileNameComparer; + string? previousPath; + lock (_lock) + { + previousPath = _eventCache + .OfType() + .FirstOrDefault(x => fileNameComparer.Equals(Path.GetFullPath(x.FullPath), path))?.OldFullPath; + } - if (newWriteTime == lastWriteTime) + if (!File.Exists(previousPath)) return false; - _lastWriteTimes[fileName] = newWriteTime; - return (newWriteTime - lastWriteTime).TotalMilliseconds >= MinWriteTimeDifference; + previousPath = Path.GetFullPath(previousPath); + lock (_lock) + { + _files.Remove(path); + _files.Add(previousPath); + } + + Moved?.Invoke(this, new(previousPath, path)); + Changed?.Invoke(this, new(WatcherChangeTypes.Changed, Path.GetDirectoryName(previousPath), Path.GetFileName(previousPath))); + return true; } /// - /// Determines if a file is being watched. + /// Tries to process a complex file modification operation that is commonly performed by some IDEs, such as Visual Studio. /// - /// The name of the file to check. - /// true if the file is being watched; otherwise, false. - private bool IsWatchingFile(string fileName) - => _files.Contains(fileName); + /// + /// Such operation involves several steps on ReFS drives: + /// + /// + /// An edited copy of the original file is created (e.g., `mgwudjxu.mzo-`). + /// + /// + /// The original file is deleted and its contents are written to a temporary file (e.g., `MainWindow.axaml-RF44d4e140.TMP`). + /// + /// + /// The edited copy is moved back to the original file's location (`mgwudjxu.mzo-` -> `MainWindow.axaml`). + /// + /// + /// If no errors occurred during the process, the original file is deleted (`MainWindow.axaml-RF44d4e140.TMP`). + /// + /// + /// In contrast to responding to each individual event, this method cumulatively processes them and emits a single "changed" event. + /// + /// The event arguments containing information about the filesystem operation. + /// true if a complex change operation was successfully processed; otherwise, false. + private bool TryProcessComplexChange_ReFS(FileSystemEventArgs args) + { + if (args is not MovedEventArgs movedArgs) + return false; + + // We only want to catch an event when an untracked file + // takes place of the one we're actually watching. + if (!IsWatchingFile(movedArgs.FullPath) || IsWatchingFile(movedArgs.OldFullPath)) + return false; + + string path = Path.GetFullPath(args.FullPath); + StringComparer fileNameComparer = FileHelper.FileNameComparer; + bool wasDeleted; + lock (_lock) + { + wasDeleted = _eventCache + .Any(x => x.ChangeType is WatcherChangeTypes.Deleted + && fileNameComparer.Equals(Path.GetFullPath(x.FullPath), path)); + } + + if (!wasDeleted || !File.Exists(path)) + return false; + + // `FileWatcher` currently does not propagate `Deleted` events to its subscribers. + // However, if this ever changes, we will need to create a synthetic `Created` event here as well. + Changed?.Invoke(this, new(WatcherChangeTypes.Changed, Path.GetDirectoryName(path), Path.GetFileName(path))); + return true; + } /// /// Disposes resources used by this file watcher.