From 4072263d77c054fb897d3267ca17c5c2d7ff6a95 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 21 Feb 2025 13:33:04 -0500 Subject: [PATCH 1/8] feat: Make the remote HR (from HD) updating the document in memory when running on VS (cherry picked from commit 777a54544f4d337ffaa92bba00d78c9c3c1a8638) --- .../IDEChannel/ForceHotReloadIdeMessage.cs | 7 +- .../HotReload/ServerHotReloadProcessor.cs | 10 +- .../AbsolutePathComparer.cs | 275 ++++++++++++++++ src/Uno.UI.RemoteControl.VS/EntryPoint.cs | 34 ++ .../HotReload/Messages/UpdateFile.cs | 9 + .../Helpers/Given_AbsolutePathComparer.cs | 295 ++++++++++++++++++ src/Uno.UI.Tests/Uno.UI.Unit.Tests.csproj | 3 + 7 files changed, 629 insertions(+), 4 deletions(-) create mode 100644 src/Uno.UI.RemoteControl.VS/AbsolutePathComparer.cs create mode 100644 src/Uno.UI.Tests/Helpers/Given_AbsolutePathComparer.cs diff --git a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/ForceHotReloadIdeMessage.cs b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/ForceHotReloadIdeMessage.cs index 2d135a11714f..8b07f20ab546 100644 --- a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/ForceHotReloadIdeMessage.cs +++ b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/ForceHotReloadIdeMessage.cs @@ -1,5 +1,10 @@ #nullable enable +using System.Collections.Generic; + namespace Uno.UI.RemoteControl.Messaging.IdeChannel; -public record ForceHotReloadIdeMessage(long CorrelationId) : IdeMessage(WellKnownScopes.HotReload); +public record ForceHotReloadIdeMessage( + long CorrelationId, + IReadOnlyDictionary? OptionalUpdatedFilesContent, + bool ForceFileSave = false) : IdeMessage(WellKnownScopes.HotReload); diff --git a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs index 1d97bac73364..7be10e9c9246 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs +++ b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs @@ -430,7 +430,11 @@ private async Task ProcessUpdateFile(UpdateFile message) if ((int)result < 300 && !message.IsForceHotReloadDisabled) { hotReload.EnableAutoRetryIfNoChanges(message.ForceHotReloadAttempts, message.ForceHotReloadDelay); - await RequestHotReloadToIde(hotReload.Id); + var filesContent = + message is { FilePath: { Length: > 0 } f, NewText: { Length: > 0 } t } + ? new System.Collections.Generic.Dictionary { { f, t } } + : null; + await RequestHotReloadToIde(hotReload.Id, filesContent, message.ForceSaveOnDisk && filesContent is not null); } await _remoteControlServer.SendFrame(new UpdateFileResponse(message.RequestId, message.FilePath ?? "", result, error, hotReload.Id)); @@ -563,9 +567,9 @@ async Task WaitForFileUpdated() } } - private async Task RequestHotReloadToIde(long sequenceId) + private async Task RequestHotReloadToIde(long sequenceId, IReadOnlyDictionary? optionalUpdatedFilesContent = null, bool forceFileSave = false) { - var hrRequest = new ForceHotReloadIdeMessage(sequenceId); + var hrRequest = new ForceHotReloadIdeMessage(sequenceId, optionalUpdatedFilesContent, forceFileSave); var hrRequested = new TaskCompletionSource(); try diff --git a/src/Uno.UI.RemoteControl.VS/AbsolutePathComparer.cs b/src/Uno.UI.RemoteControl.VS/AbsolutePathComparer.cs new file mode 100644 index 000000000000..0892f3672365 --- /dev/null +++ b/src/Uno.UI.RemoteControl.VS/AbsolutePathComparer.cs @@ -0,0 +1,275 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Runtime.CompilerServices; + +namespace Uno.UI.Helpers; + +/// +/// Compares two absolute path strings for equality. Supports Windows, Unix, and UNC path formats. URIs are not supported. +/// +/// +/// This will resolve "." and ".." segments and normalize slashes vs backslashes before doing the comparison. +/// Relative paths will always compare as unequal. Same for empty paths (C:\ or / on unix/max). +/// IMPORTANT: This comparer WILL NOT resolve symbolic links or check if the filesystem is case-sensitive. +/// It's just comparing the strings as paths, no I/O is performed. +/// +internal class AbsolutePathComparer : IEqualityComparer +{ + private readonly bool _ignoreCase; + + public static readonly AbsolutePathComparer Comparer = new(ignoreCase: false); + public static readonly AbsolutePathComparer ComparerIgnoreCase = new(ignoreCase: true); + + private enum PathType + { + Relative, + Unix, + Windows, + UNC, + } + + private AbsolutePathComparer(bool ignoreCase = true) + { + _ignoreCase = ignoreCase; + } + + /// + /// Determines if two path strings are equal. + /// + public bool Equals(string? x, string? y) + { + if (x == null || y == null) + { + return false; + } + + var spanX = x.AsSpan(); + var spanY = y.AsSpan(); + + // Parse each path into a drive (if any) and segments + var (typeX, driveX, segmentsX) = ParsePath(spanX); + var (typeY, driveY, segmentsY) = ParsePath(spanY); + + // Compare path types (e.g., relative, UNC, Windows, or Unix) + if (typeX != typeY) + { + return false; + } + + // Compare drive letters (e.g., "C:" vs "D:") or empty if UNC or no drive + if (driveX != driveY) + { + return false; + } + + // Compare the number of segments, ensure at least one segment + if (segmentsX.Count == 0 || segmentsX.Count != segmentsY.Count) + { + return false; + } + + // Compare each corresponding segment + for (var i = 0; i < segmentsX.Count; i++) + { + var segX = segmentsX[i]; + var segY = segmentsY[i]; + + if (!SegmentEquals(spanX, segX.start, segX.length, spanY, segY.start, segY.length)) + { + return false; + } + } + + return true; + } + + /// + /// Computes a hash code for the given path string. + /// + public int GetHashCode(string? path) + { + if (path is null || path.Length == 0) + { + return -1; + } + + var (type, drive, segments) = ParsePath(path.AsSpan()); + + var hash = 17; + + // Include the path type in the hash + hash = unchecked(hash * 79 + (int)type); + + // Include the drive in the hash (no effect is drive is '\0') + hash = unchecked(hash * 31 + NormalizeChar(drive)); + + // Include each segment in the hash + foreach (var (start, length) in segments) + { + for (var i = 0; i < length; i++) + { + var c = path[start + i]; + hash = unchecked(hash * 31 + NormalizeChar(c)); + } + // Use a slash as a separator between segments + hash = unchecked(hash * 31 + '/'); + } + + return hash; + } + + /// + /// Parses a path into an optional drive (e.g., "C:") and a list of segments. + /// Also handles UNC paths that begin with "//" or "\\". + /// + private (PathType type, char drive, List<(int start, int length)>) ParsePath(ReadOnlySpan path) + { + if (!IsPathFullyQualified(path)) + { + return (PathType.Relative, '\0', []); // Only fully-qualified paths are supported + } + + var segments = new List<(int start, int length)>(); + + var type = PathType.Unix; // Assume Unix-style path + var drive = '\0'; // '\0' means no drive letter + var idx = 0; + + // 1) Check for UNC path (starts with "//" or "\\") + if (path.Length >= 2 && IsPathSegmentSeparator(path[0]) && IsPathSegmentSeparator(path[1])) + { + // Skip the initial double slash + type = PathType.UNC; + idx = 2; + } + // 2) Otherwise, check for local drive (e.g., "C:") + else if (path.Length >= 2 && path[1] == ':') + { + // Extract the drive letter advance to the next character after the colon + type = PathType.Windows; + drive = char.ToUpper(path[0], CultureInfo.InvariantCulture); + idx = 2; + } + + // Skip any additional slashes after drive or UNC prefix + while (idx < path.Length && IsPathSegmentSeparator(path[idx])) + { + idx++; + } + + // Parse segments until we reach the end + while (idx < path.Length) + { + var segStart = idx; + + // Move until the next slash or end of string + while (idx < path.Length && !IsPathSegmentSeparator(path[idx])) + { + idx++; + } + + var segLength = idx - segStart; + + if (segLength == 1 && path[segStart] == '.') + { + // Ignore single-dot segments + } + else if (segLength == 2 && path[segStart] == '.' && path[segStart + 1] == '.') + { + // Pop the last segment for ".." if possible + if (segments.Count > 0) + { + segments.RemoveAt(segments.Count - 1); + } + } + else if (segLength > 0) + { + // Normal segment => record its start/length + segments.Add((segStart, segLength)); + } + + // Skip any consecutive slashes + while (idx < path.Length && IsPathSegmentSeparator(path[idx])) + { + idx++; + } + } + + return (type, drive, segments); + } + + // Determines if the given path is fully qualified (e.g., "C:\foo", "\\server\share\foo", or "/foo"). + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsPathFullyQualified(ReadOnlySpan path) + { + if (path.Length == 0) + { + return false; + } + + // Unix-like, UNC or non-drive specific path + if (IsPathSegmentSeparator(path[0])) + { + return true; + } + + // Drive letter (must be followed by a colon and a path separator) - must also be a valid drive letter + if (path.Length >= 3 && path[1] == ':' && IsPathSegmentSeparator(path[2]) && char.IsLetter(path[0])) + { + return true; + } + + // Relative path + return false; + } + + /// + /// Compares two path segments [start..start+length] for equality (respecting or ignoring case). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool SegmentEquals(ReadOnlySpan a, int startA, int lenA, ReadOnlySpan b, int startB, int lenB) + { + if (lenA != lenB) + { + return false; // Should never happen (already checked in the caller) + } + + if (_ignoreCase) + { + for (var i = 0; i < lenA; i++) + { + if (char.ToLowerInvariant(a[startA + i]) != char.ToLowerInvariant(b[startB + i])) + { + return false; + } + } + } + else + { + for (var i = 0; i < lenA; i++) + { + if (a[startA + i] != b[startB + i]) + { + return false; + } + } + } + + return true; + } + + /// + /// Normalizes a character (e.g., converting it to lowercase if ignoring case). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private char NormalizeChar(char c) => _ignoreCase ? char.ToLowerInvariant(c) : c; + + /// + /// Returns true if the given character is a slash ('/' or '\'). + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsPathSegmentSeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; +} diff --git a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs index 71177530e449..f0b8b2f397aa 100644 --- a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs +++ b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs @@ -18,6 +18,7 @@ using Microsoft.VisualStudio.Shell; using Microsoft.VisualStudio.Shell.Interop; using Microsoft.VisualStudio.Threading; +using Uno.UI.Helpers; using Uno.UI.RemoteControl.Messaging.IdeChannel; using Uno.UI.RemoteControl.VS.DebuggerHelper; using Uno.UI.RemoteControl.VS.Helpers; @@ -538,6 +539,39 @@ private async Task OnForceHotReloadRequestedAsync(object? sender, ForceHotReload { try { + if (request.OptionalUpdatedFilesContent is { Count: > 0 } fileUpdates) + { + foreach (var file in fileUpdates) + { + var filePath = file.Key; + var fileContent = file.Value; + + if (fileContent is not { Length: > 0 }) + { + continue; // Skip empty content. + } + + // Update the file content in the IDE using the DTE API. + var document = _dte2.Documents + .OfType() + .FirstOrDefault(d => AbsolutePathComparer.ComparerIgnoreCase.Equals(d.FullName, filePath)); + + if (document?.Object("TextDocument") is TextDocument textDocument) + { + // Replace the content of the document with the new content. + + // Flags: 0b0000_0011 = vsEPReplaceTextOptions.vsEPReplaceTextKeepMarkers | vsEPReplaceTextOptions.vsEPReplaceTextNormalizeNewLines + // https://learn.microsoft.com/en-us/dotnet/api/envdte.vsepreplacetextoptions?view=visualstudiosdk-2022#fields + const int flags = 0b0000_0011; + + textDocument.StartPoint.CreateEditPoint() + .ReplaceText(textDocument.EndPoint, fileContent, flags); + } + } + } + + // Programmatically trigger the "Apply Code Changes" command in Visual Studio. + // Which will trigger the hot reload. _dte.ExecuteCommand("Debug.ApplyCodeChanges"); // Send a message back to indicate that the request has been received and acted upon. diff --git a/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs b/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs index d489d8c94832..20a98af50b44 100644 --- a/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs +++ b/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs @@ -31,6 +31,15 @@ public class UpdateFile : IMessage [JsonProperty] public string? NewText { get; set; } + /// + /// If true, the file will be saved on disk, even if the content is the same. + /// + /// + /// Currently, this is only used for VisualStudio, because the update requires a file save on disk for other IDEs. + /// On VisualStudio, the save to disk is not required for doing Hot Reload. + /// + public bool ForceSaveOnDisk { get; set; } + /// /// Indicates if the file can be created or deleted. /// diff --git a/src/Uno.UI.Tests/Helpers/Given_AbsolutePathComparer.cs b/src/Uno.UI.Tests/Helpers/Given_AbsolutePathComparer.cs new file mode 100644 index 000000000000..90321a5c8b1c --- /dev/null +++ b/src/Uno.UI.Tests/Helpers/Given_AbsolutePathComparer.cs @@ -0,0 +1,295 @@ +#nullable enable +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Uno.UI.Helpers; + +namespace Uno.UI.Tests.Helpers; + +[TestClass] +public class Given_AbsolutePathComparer +{ + [TestMethod] + // (path1, path2, expected equality) + [DataRow(@"C:\folder", @"C:\folder", true, DisplayName = "Same path")] + [DataRow(@"C:\FOLDER", @"c:\folder", true, DisplayName = "Same path, different case")] + [DataRow(@"C:\FOLDER\sub-folder\file.ABC", @"c:/folder/sub-folder/file.abc", true, DisplayName = "Same path, different separator + case")] + [DataRow(@"C:\folder1", @"C:\folder2", false, DisplayName = "Different path")] + [DataRow(@"C:\folder", @"D:\folder", false, DisplayName = "Different drive")] + [DataRow(@"C:\folder", @"C:\folder\subfolder", false, DisplayName = "Different path length")] + [DataRow(@"C:folder", @"C:folder", false, DisplayName = "Invalid path (no slash)")] + [DataRow(@".\something", @"something", false, DisplayName = "Relative path (invalid)")] + [DataRow(@"\\server\share", @"\\server\share", true, DisplayName = "UNC path")] + [DataRow(@"\\server\share", @"/server/share", false, DisplayName = "UNC vs absolute path")] + [DataRow(@"\\SERVER\share", @"\\server\Share\sub1\sub2\..\..", true, DisplayName = "UNC path, different case, with 2x'..'")] + [DataRow(@"\\server\share\folder", @"\\server\share\Folder", true, DisplayName = "UNC path, folder case difference")] + [DataRow(@"\\server\share\folder\..\sub", @"\\server\share\sub", true, DisplayName = "UNC path with '..' (parent folder)")] + [DataRow(@"/usr/local/bin", @"/usr/local/bin", true, DisplayName = "Unix path")] + [DataRow(@"/usr/local/bin", @"/usr/LOCAL/bin", true, DisplayName = "Unix path, different case")] + [DataRow(@"/usr/local/bin", @"/usr/local/lib", false, DisplayName = "Unix path, different")] + public void GivenCaseInsensitive_WhenEqualsIsCalled_ThenItReturnsExpectedResult(string? path1, string? path2, bool expected) + { + // Arrange + var comparer = AbsolutePathComparer.ComparerIgnoreCase; // or new AbsolutePathComparer(true); + + // Act + var result = comparer.Equals(path1, path2); + + // Assert + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(null, null, false, DisplayName = "null vs null => false")] + [DataRow(null, @"C:\folder", false, DisplayName = "null vs path => false")] + [DataRow(@"C:\folder", null, false, DisplayName = "path vs null => false")] + [DataRow("", "", false, DisplayName = "empty => not fully qualified => false")] + [DataRow("/", "/", false, DisplayName = "Unix root => 0 segment => false")] + [DataRow("/../../..", "/../../..", false, DisplayName = "Invalid backtracking => false")] + [DataRow(@"C:\folder\", @"C:\folder", true, DisplayName = "ending slash => true")] + [DataRow(@"C:\folder\\sub", @"C:\folder\sub", true, DisplayName = "duplicate slashes => true")] + [DataRow(@"C:\folder\.", @"C:\folder", true, DisplayName = "'.' ignored => true")] + [DataRow(@"C:\.\.\.\./././folder\.", @"C:\folder/././", true, DisplayName = "multiple '.' ignored => true")] + [DataRow(@"C:\folder\sub\..", @"C:\folder", true, DisplayName = "'..' => true")] + [DataRow(@"C:\folder\sub\..\..", @"C:\", false, DisplayName = "2x '..' => 0 segments => false")] + [DataRow(@"C:\folder", @"c:\folder", true, DisplayName = "Different case => true")] + public void GivenCaseInsensitive_WhenEqualsIsCalled_WithEdgeCases_ThenItReturnsExpectedResult(string? path1, string? path2, bool expected) + { + // Arrange + var comparer = AbsolutePathComparer.ComparerIgnoreCase; + + // Act + var result = comparer.Equals(path1, path2); + + // Assert + Assert.AreEqual(expected, result, $"Expected {expected} for '{path1}' and '{path2}', but got {result}."); + } + + [TestMethod] + // (path1, path2, expected equality) + [DataRow(@"C:\folder", @"C:\folder", true, DisplayName = "Same path (case-sensitive)")] + [DataRow(@"C:\folder", @"c:\folder", true, DisplayName = "Same path, different case for drive letter => true")] + [DataRow(@"C:\folder", @"C:\FOLDER", false, DisplayName = "Case mismatch => not equal")] + [DataRow(@"/usr/local/bin", @"/usr/local/bin", true, DisplayName = "Same Unix path (case-sensitive)")] + [DataRow(@"/usr/local/bin", @"/usr/LOCAL/bin", false, DisplayName = "Case mismatch (Unix)")] + [DataRow(@"C:folder", @"C:folder", false, DisplayName = "Invalid path, no slash")] + public void GivenCaseSensitive_WhenEqualsIsCalled_ThenItReturnsExpectedResult(string? path1, string? path2, bool expected) + { + // Arrange + var comparer = AbsolutePathComparer.Comparer; // or new AbsolutePathComparer(false); + + // Act + var result = comparer.Equals(path1, path2); + + // Assert + Assert.AreEqual(expected, result); + } + + [TestMethod] + [DataRow(@"C:\folder", @"C:\folder", true, DisplayName = "Same path (case-sensitive)")] + [DataRow(@"C:\folder", @"c:\folder", true, DisplayName = "Same path, different case => true")] + [DataRow(@"C:\folder", @"c:\FOLDER", true, DisplayName = "Same path, different case => true")] + [DataRow(@"C:\folder1", @"C:\folder2", false, DisplayName = "Different path")] + [DataRow(null, @"C:\folder2", false, DisplayName = "null vs path => false")] + [DataRow(@"C:\folder1", null, false, DisplayName = "path vs null => false")] + public void GivenAPathCaseInsensitive_WhenGetHashCodeIsCalled_ThenItIsConsistentWithEquals(string? path1, string? path2, bool expectSameHash) + { + // Arrange + var comparer = AbsolutePathComparer.ComparerIgnoreCase; // or new AbsolutePathComparer(true); + + // Act + var hash1 = comparer.GetHashCode(path1); + var hash2 = comparer.GetHashCode(path2); + var sameHash = (hash1 == hash2); + + // Assert + if (expectSameHash) + { + Assert.IsTrue(sameHash, $"Expected same hash code for '{path1}' and '{path2}', but got {hash1} and {hash2}."); + } + else + { + Assert.IsFalse(sameHash, $"Expected different hash code for '{path1}' and '{path2}', but both got {hash1}."); + } + } + + [TestMethod] + [DataRow(@"C:\folder", @"C:\folder", true, DisplayName = "Same path (case-sensitive)")] + [DataRow(@"C:\folder", @"C:\FOLDER", false, DisplayName = "Case mismatch => not equal")] + [DataRow(@"/usr/local/bin", @"/usr/local/bin", true, DisplayName = "Same Unix path (case-sensitive)")] + [DataRow(@"/usr/local/bin", @"/usr/LOCAL/bin", false, DisplayName = "Case mismatch (Unix)")] + public void GivenAPathCaseSensitive_WhenGetHashCodeIsCalled_ThenItIsConsistentWithEquals(string? path1, string? path2, bool expectSameHash) + { + // Arrange + var comparer = AbsolutePathComparer.Comparer; // or new AbsolutePathComparer(false); + + // Act + var hash1 = comparer.GetHashCode(path1); + var hash2 = comparer.GetHashCode(path2); + var sameHash = (hash1 == hash2); + + // Assert + if (expectSameHash) + { + Assert.IsTrue(sameHash, $"Expected same hash code for '{path1}' and '{path2}', but got {hash1} and {hash2}."); + } + else + { + Assert.IsFalse(sameHash, $"Expected different hash code for '{path1}' and '{path2}', but both got {hash1}."); + } + } + + [TestMethod] + // (path1, path2, expectedEquals) + // Both are UNC + [DataRow(@"\\server\share", @"\\server\share", true, DisplayName = "UNC vs UNC (identical) => true")] + [DataRow(@"\\Server\Share", @"\\server\share", true, DisplayName = "UNC vs UNC (case diff) => true (ignoreCase)")] + [DataRow(@"\\server\Share\..", @"\\server\", true, DisplayName = "UNC path with '..' => same final segments => true")] + [DataRow(@"\\server\share", @"/server/share", false, DisplayName = "UNC vs Unix => false")] + + // Both are Windows + [DataRow(@"C:\folder", @"C:\folder", true, DisplayName = "Windows vs Windows => true")] + [DataRow(@"C:\FOLDER", @"c:\folder", true, DisplayName = "Windows drive letter / segment case => true (ignoreCase)")] + [DataRow(@"C:\folder\..", @"C:\", false, DisplayName = "Windows path with '..' => 0 segments => false (code requires >= 1)")] + + // Both are Unix + [DataRow(@"/usr/bin", @"/usr/bin", true, DisplayName = "Unix vs Unix => true")] + [DataRow(@"/usr/BIN", @"/usr/bin", true, DisplayName = "Unix vs Unix (case diff) => true (ignoreCase)")] + [DataRow(@"/usr/bin/..", @"/usr", true, DisplayName = "Unix path with '..' => true")] + + // Relative checks + [DataRow(@"folder\sub", @"folder\sub", false, DisplayName = "Relative vs Relative => false (no segments)")] + [DataRow(@"C:folder", @"C:\folder", false, DisplayName = "Relative (C:folder) vs Windows (C:\\folder) => false")] + [DataRow(@".\folder", @"..\folder", false, DisplayName = "Both relative => still false (code doesn't handle them as fully qualified)")] + + public void WhenComparingPaths_CaseInsensitive_ThenResultIsAsExpected(string? path1, string? path2, bool expectedEquals) + { + // Arrange + var comparer = AbsolutePathComparer.ComparerIgnoreCase; + + // Act + var result = comparer.Equals(path1, path2); + + // Assert + Assert.AreEqual(expectedEquals, result,"Case-insensitive: '{path1}' vs '{path2}' should be {expectedEquals}."); + } + + [TestMethod] + // (path1, path2, expectedEquals) + // UNC + [DataRow(@"\\server\share", @"\\server\share", true, DisplayName = "UNC vs UNC => true")] + [DataRow(@"\\Server\Share", @"\\server\share", false, DisplayName = "UNC vs UNC => false if case is different in segments")] + [DataRow(@"\\server\share", @"/server/share", false, DisplayName = "UNC vs Unix => false")] + + // Windows + [DataRow(@"C:\folder", @"C:\folder", true, DisplayName = "Windows vs Windows (exact case) => true")] + [DataRow(@"C:\folder", @"C:\FOLDER", false, DisplayName = "Windows vs Windows (case diff) => false")] + [DataRow(@"C:\folder\..", @"C:\", false, DisplayName = "Windows '..' => drive root => false")] + + // Unix + [DataRow(@"/usr/bin", @"/usr/bin", true, DisplayName = "Unix vs Unix => true")] + [DataRow(@"/usr/BIN", @"/usr/bin", false, DisplayName = "Unix vs Unix (case diff) => false")] + [DataRow(@"/usr/bin/..", @"/usr", true, DisplayName = "Unix '..' => 1 segment => true")] + [DataRow(@"/usr/..", @"/", false, DisplayName = "Unix '..' => 0 segments => false")] + + // Relative + [DataRow(@"folder\sub", @"folder\sub", false, DisplayName = "Relative vs Relative => false (no segments)")] + [DataRow(@"C:folder", @"C:\folder", false, DisplayName = "Relative vs Windows => false")] + + public void WhenComparingPaths_CaseSensitive_ThenResultIsAsExpected(string? path1, string? path2, bool expectedEquals) + { + // Arrange + var comparer = AbsolutePathComparer.Comparer; + + // Act + var result = comparer.Equals(path1, path2); + + // Assert + Assert.AreEqual(expectedEquals, result,"Case-sensitive: '{path1}' vs '{path2}' should be {expectedEquals}."); + } + + [TestMethod] + // (path1, path2, expectSameHash) + [DataRow(@"\\server\share", @"\\server\share", true, DisplayName = "UNC vs UNC => same hash")] + [DataRow(@"\\server\share", @"/server/share", false, DisplayName = "UNC vs Unix => different hash (types differ)")] + [DataRow(@"C:\folder", @"C:\folder", true, DisplayName = "Windows vs Windows => same hash")] + [DataRow(@"C:\folder", @"C:\FOLDER", true, DisplayName = "Windows vs Windows (ignoreCase) => same hash if ignoring case")] + [DataRow(@"/usr/bin", @"/usr/bin", true, DisplayName = "Unix vs Unix => same hash")] + [DataRow(@"folder\sub", @"folder\sub", true, DisplayName = "Relative vs Relative => both parse to no segments => same hash = 0? Actually let's see.")] + public void WhenGetHashCode_CaseInsensitive_ThenItMatchesEquality(string? path1, string? path2, bool expectSameHash) + { + // Arrange + var comparer = AbsolutePathComparer.ComparerIgnoreCase; + + // Act + var equalsResult = comparer.Equals(path1, path2); + var hash1 = comparer.GetHashCode(path1); + var hash2 = comparer.GetHashCode(path2); + var sameHash = (hash1 == hash2); + + // Assert + if (equalsResult) + { + Assert.IsTrue(sameHash, $"Paths '{path1}' and '{path2}' are equal, so they should have the same hash code. Got: {hash1}, {hash2}."); + } + else if (expectSameHash) + { + Assert.IsTrue(sameHash, $"Expected same hash code for '{path1}' and '{path2}', but got {hash1} vs {hash2}."); + } + else + { + // Usually we expect different hash, but collisions are still possible in theory. + Assert.IsFalse(sameHash, $"Expected different hash codes for '{path1}' and '{path2}', but both are {hash1}."); + } + } + + [TestMethod] + [DataRow(@"\\server\share", @"\\server\share", true, DisplayName = "UNC vs UNC => same hash (exact)")] + [DataRow(@"\\Server\share", @"\\server\share", false, DisplayName = "UNC vs UNC (case diff in first segment) => different hash")] + [DataRow(@"C:\folder", @"C:\folder", true, DisplayName = "Windows vs Windows => same hash")] + [DataRow(@"C:\folder", @"C:\FOLDER", false, DisplayName = "Windows vs Windows (case diff) => different hash")] + [DataRow(@"/usr/bin", @"/usr/bin", true, DisplayName = "Unix vs Unix => same hash")] + [DataRow(@"/usr/bin", @"/usr/BIN", false, DisplayName = "Unix vs Unix (case diff) => different hash")] + [DataRow(@"folder\sub", @"folder\sub", true, DisplayName = "Relative => parse as no segments => same hash = 0?")] + public void WhenGetHashCode_CaseSensitive_ThenItMatchesEquality(string? path1, string? path2, bool expectSameHash) + { + // Arrange + var comparer = AbsolutePathComparer.Comparer; + + // Act + var equalsResult = comparer.Equals(path1, path2); + var hash1 = comparer.GetHashCode(path1); + var hash2 = comparer.GetHashCode(path2); + var sameHash = (hash1 == hash2); + + // Assert + if (equalsResult) + { + Assert.IsTrue(sameHash, $"Paths '{path1}' and '{path2}' are equal, so they should have the same hash code. Got: {hash1}, {hash2}."); + } + else if (expectSameHash) + { + Assert.IsTrue(sameHash, $"Expected same hash code for '{path1}' and '{path2}', but got {hash1} vs {hash2}."); + } + else + { + // Collisions can happen, but typically we expect different values. + Assert.IsFalse(sameHash, $"Expected different hash codes for '{path1}' and '{path2}', but both are {hash1}."); + } + } + + [TestMethod] + [DataRow(@"C:\folder\🦄", @"C:\folder\🦄", true, DisplayName = "Windows path with emoji")] + [DataRow("/usr/local/🦄", "/usr/local/🦄", true, DisplayName = "Unix path with emoji")] + [DataRow(@"C:\folder\مرحبا", @"C:\folder\مرحبا", true, DisplayName = "Windows path with Arabic")] + [DataRow("/usr/local/你好", "/usr/local/你好", true, DisplayName = "Unix path with Chinese")] + public void GivenPaths_WhenComparingWithUnicodeCharacters_ThenItReturnsExpectedResult(string? path1, string? path2, bool expectedEquals) + { + // Arrange + var comparer = AbsolutePathComparer.ComparerIgnoreCase; + + // Act + var result = comparer.Equals(path1, path2); + + // Assert + Assert.AreEqual(expectedEquals, result, $"Paths '{path1}' and '{path2}' should be {expectedEquals}."); + } +} diff --git a/src/Uno.UI.Tests/Uno.UI.Unit.Tests.csproj b/src/Uno.UI.Tests/Uno.UI.Unit.Tests.csproj index d0abd66bcbbf..57d5399c7cb7 100644 --- a/src/Uno.UI.Tests/Uno.UI.Unit.Tests.csproj +++ b/src/Uno.UI.Tests/Uno.UI.Unit.Tests.csproj @@ -68,6 +68,9 @@ + + Helpers\AbsolutePathComparer.cs + From fa0b78a790460c158b756ce3768c199a749b1f54 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 24 Feb 2025 09:58:08 -0500 Subject: [PATCH 2/8] feat: Detect if we're running on VS and delegate the SAVE to it when it's the case. (cherry picked from commit 04ba6c9121b0977b3fb02b7cc591b030abff7d47) --- ...ServerHotReloadProcessor.MetadataUpdate.cs | 11 ++---- .../HotReload/ServerHotReloadProcessor.cs | 24 ++++++++++-- .../AbsolutePathComparer.cs | 4 +- src/Uno.UI.RemoteControl.VS/EntryPoint.cs | 37 +++++++++++++++---- .../ClientHotReloadProcessor.ClientApi.cs | 3 +- 5 files changed, 59 insertions(+), 20 deletions(-) diff --git a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.MetadataUpdate.cs b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.MetadataUpdate.cs index 0ac24db15b77..f33f2df4b776 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.MetadataUpdate.cs +++ b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.MetadataUpdate.cs @@ -1,6 +1,7 @@ #nullable enable using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; @@ -36,7 +37,7 @@ partial class ServerHotReloadProcessor : IServerProcessor, IDisposable private bool _useRoslynHotReload; - private bool InitializeMetadataUpdater(ConfigureServer configureServer) + private bool InitializeMetadataUpdater(ConfigureServer configureServer, Dictionary properties) { _ = bool.TryParse(_remoteControlServer.GetServerConfiguration("metadata-updates"), out _useRoslynHotReload); @@ -49,7 +50,7 @@ private bool InitializeMetadataUpdater(ConfigureServer configureServer) // of a missing path on assemblies loaded from a memory stream. CompilationWorkspaceProvider.RegisterAssemblyLoader(); - InitializeInner(configureServer); + InitializeInner(configureServer, properties); return true; } @@ -59,7 +60,7 @@ private bool InitializeMetadataUpdater(ConfigureServer configureServer) } } - private void InitializeInner(ConfigureServer configureServer) + private void InitializeInner(ConfigureServer configureServer, Dictionary properties) { if (Assembly.Load("Microsoft.CodeAnalysis.Workspaces") is { } wsAsm) { @@ -81,10 +82,6 @@ private void InitializeInner(ConfigureServer configureServer) { await Notify(HotReloadEvent.Initializing); - var properties = configureServer - .MSBuildProperties - .ToDictionary(); - // Flag the current build as created for hot reload, which allows for running targets or settings // props/items in the context of the hot reload workspace. properties["UnoIsHotReloadHost"] = "True"; diff --git a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs index 7be10e9c9246..9aae8670ff9a 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs +++ b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs @@ -35,6 +35,16 @@ public ServerHotReloadProcessor(IRemoteControlServer remoteControlServer) public string Scope => WellKnownScopes.HotReload; + private bool _isRunningInsideVisualStudio; + + private void InterpretMsbuildProperties(IDictionary properties) + { + // This is called from ProcessConfigureServer() during initialization. + + _isRunningInsideVisualStudio = properties + .TryGetValue("BuildingInsideVisualStudio", out var vs) && vs.Equals("true", StringComparison.OrdinalIgnoreCase); + } + public async Task ProcessFrame(Frame frame) { switch (frame.Name) @@ -397,7 +407,13 @@ private void ProcessConfigureServer(ConfigureServer configureServer) this.Log().LogDebug($"Base project path: {configureServer.ProjectPath}"); } - if (InitializeMetadataUpdater(configureServer)) + var properties = configureServer + .MSBuildProperties + .ToDictionary(); + + InterpretMsbuildProperties(properties); + + if (InitializeMetadataUpdater(configureServer, properties)) { this.Log().LogDebug($"Metadata updater initialized"); } @@ -422,7 +438,7 @@ private async Task ProcessUpdateFile(UpdateFile message) var (result, error) = message switch { { FilePath: null or { Length: 0 } } => (FileUpdateResult.BadRequest, "Invalid request (file path is empty)"), - { OldText: not null, NewText: not null } => await DoUpdate(message.OldText, message.NewText), + { OldText: not null, NewText: not null } when !_isRunningInsideVisualStudio => await DoUpdate(message.OldText, message.NewText), { OldText: null, NewText: not null } => await DoWrite(message.NewText), { NewText: null, IsCreateDeleteAllowed: true } => await DoDelete(), _ => (FileUpdateResult.BadRequest, "Invalid request") @@ -434,7 +450,9 @@ private async Task ProcessUpdateFile(UpdateFile message) message is { FilePath: { Length: > 0 } f, NewText: { Length: > 0 } t } ? new System.Collections.Generic.Dictionary { { f, t } } : null; - await RequestHotReloadToIde(hotReload.Id, filesContent, message.ForceSaveOnDisk && filesContent is not null); + + var forceFileSaveInIde = _isRunningInsideVisualStudio && filesContent is not null; + await RequestHotReloadToIde(hotReload.Id, filesContent, forceFileSaveInIde); } await _remoteControlServer.SendFrame(new UpdateFileResponse(message.RequestId, message.FilePath ?? "", result, error, hotReload.Id)); diff --git a/src/Uno.UI.RemoteControl.VS/AbsolutePathComparer.cs b/src/Uno.UI.RemoteControl.VS/AbsolutePathComparer.cs index 0892f3672365..79a75cab4740 100644 --- a/src/Uno.UI.RemoteControl.VS/AbsolutePathComparer.cs +++ b/src/Uno.UI.RemoteControl.VS/AbsolutePathComparer.cs @@ -22,7 +22,7 @@ internal class AbsolutePathComparer : IEqualityComparer public static readonly AbsolutePathComparer Comparer = new(ignoreCase: false); public static readonly AbsolutePathComparer ComparerIgnoreCase = new(ignoreCase: true); - + private enum PathType { Relative, @@ -99,7 +99,7 @@ public int GetHashCode(string? path) var (type, drive, segments) = ParsePath(path.AsSpan()); var hash = 17; - + // Include the path type in the hash hash = unchecked(hash * 79 + (int)type); diff --git a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs index f0b8b2f397aa..6e1d7fb443c1 100644 --- a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs +++ b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs @@ -24,6 +24,7 @@ using Uno.UI.RemoteControl.VS.Helpers; using Uno.UI.RemoteControl.VS.IdeChannel; using Uno.UI.RemoteControl.VS.Notifications; +using Constants = EnvDTE.Constants; using ILogger = Uno.UI.RemoteControl.VS.Helpers.ILogger; using Task = System.Threading.Tasks.Task; @@ -556,16 +557,38 @@ private async Task OnForceHotReloadRequestedAsync(object? sender, ForceHotReload .OfType() .FirstOrDefault(d => AbsolutePathComparer.ComparerIgnoreCase.Equals(d.FullName, filePath)); - if (document?.Object("TextDocument") is TextDocument textDocument) + var textDocument = document?.Object("TextDocument") as TextDocument; + + if (textDocument is null) // The document is not open in the IDE, so we need to open it. + { + // Resolve the path to the document (in case it's not open in the IDE). + // The path may contain a mix of forward and backward slashes, so we normalize it by using Path.GetFullPath. + var adjustedPathForOpening = Path.GetFullPath(filePath); + + document = _dte2.Documents.Open(adjustedPathForOpening); + textDocument = document?.Object("TextDocument") as TextDocument; + } + + if (document is null || textDocument is null) { - // Replace the content of the document with the new content. + throw new InvalidOperationException($"Failed to open document {filePath}"); + } + + document.Activate(); // Sometimes the document is "soft closed", so we need to activate it for the user to see it. + + // Replace the content of the document with the new content. - // Flags: 0b0000_0011 = vsEPReplaceTextOptions.vsEPReplaceTextKeepMarkers | vsEPReplaceTextOptions.vsEPReplaceTextNormalizeNewLines - // https://learn.microsoft.com/en-us/dotnet/api/envdte.vsepreplacetextoptions?view=visualstudiosdk-2022#fields - const int flags = 0b0000_0011; + // Flags: 0b0000_0011 = vsEPReplaceTextOptions.vsEPReplaceTextKeepMarkers | vsEPReplaceTextOptions.vsEPReplaceTextNormalizeNewLines + // https://learn.microsoft.com/en-us/dotnet/api/envdte.vsepreplacetextoptions?view=visualstudiosdk-2022#fields + const int flags = 0b0000_0011; - textDocument.StartPoint.CreateEditPoint() - .ReplaceText(textDocument.EndPoint, fileContent, flags); + textDocument.StartPoint.CreateEditPoint() + .ReplaceText(textDocument.EndPoint, fileContent, flags); + + if (request.ForceFileSave) + { + // Save the document. + document.Save(); } } } diff --git a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs index a7e6f594ec58..c9594a2e6506 100644 --- a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs +++ b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs @@ -132,7 +132,8 @@ public async Task TryUpdateFileAsync(UpdateRequest req, Cancellati OldText = req.OldText, NewText = req.NewText, ForceHotReloadDelay = req.HotReloadNoChangesRetryDelay, - ForceHotReloadAttempts = req.HotReloadNoChangesRetryAttempts + ForceHotReloadAttempts = req.HotReloadNoChangesRetryAttempts, + ForceSaveOnDisk = true, }; var response = await UpdateFileCoreAsync(request, req.ServerUpdateTimeout, ct); From 5c4639237610a0216fb8fe2be63a29028bc47935 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 24 Feb 2025 11:14:35 -0500 Subject: [PATCH 3/8] feat: Make the HR client code (HD) able to decide if the source should be forced to be saved to disk or not. Default to true for now, will change eventually. (cherry picked from commit 26e971ce5eaf581929e0b90e03a3c952f04a1794) --- .../HotReload/ServerHotReloadProcessor.cs | 2 ++ .../HotReload/ClientHotReloadProcessor.ClientApi.cs | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs index 9aae8670ff9a..ca50f25addc7 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs +++ b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs @@ -438,6 +438,8 @@ private async Task ProcessUpdateFile(UpdateFile message) var (result, error) = message switch { { FilePath: null or { Length: 0 } } => (FileUpdateResult.BadRequest, "Invalid request (file path is empty)"), + // Right now, when running on VS, we're delegating the file update to the code that is running inside VS. + // we're not doing this for other file operations because they are not/less required for hot-reload. We may need to revisit this eventually. { OldText: not null, NewText: not null } when !_isRunningInsideVisualStudio => await DoUpdate(message.OldText, message.NewText), { OldText: null, NewText: not null } => await DoWrite(message.NewText), { NewText: null, IsCreateDeleteAllowed: true } => await DoDelete(), diff --git a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs index c9594a2e6506..9563dfb111f5 100644 --- a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs +++ b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs @@ -40,6 +40,7 @@ public record struct UpdateRequest( string FilePath, string? OldText, string? NewText, + bool ForceSaveToDisk = true, // Temporary set to true until this issue is fixed: https://github.com/unoplatform/uno.hotdesign/issues/3454 bool WaitForHotReload = true) { /// @@ -95,6 +96,9 @@ public UpdateRequest Undo(bool waitForHotReload) public Task UpdateFileAsync(string filePath, string? oldText, string newText, bool waitForHotReload, CancellationToken ct) => UpdateFileAsync(new UpdateRequest(filePath, oldText, newText, waitForHotReload), ct); + public Task UpdateFileAsync(string filePath, string? oldText, string newText, bool waitForHotReload, bool forceSaveToDisk, CancellationToken ct) + => UpdateFileAsync(new UpdateRequest(filePath, oldText, newText, waitForHotReload, forceSaveToDisk), ct); + public async Task UpdateFileAsync(UpdateRequest req, CancellationToken ct) { if (await TryUpdateFileAsync(req, ct) is { Error: { } error }) @@ -133,7 +137,7 @@ public async Task TryUpdateFileAsync(UpdateRequest req, Cancellati NewText = req.NewText, ForceHotReloadDelay = req.HotReloadNoChangesRetryDelay, ForceHotReloadAttempts = req.HotReloadNoChangesRetryAttempts, - ForceSaveOnDisk = true, + ForceSaveOnDisk = req.ForceSaveToDisk, }; var response = await UpdateFileCoreAsync(request, req.ServerUpdateTimeout, ct); From bcb5c8847b058d3b885103ed318b2af66c50958f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 24 Feb 2025 16:11:12 -0500 Subject: [PATCH 4/8] chore: Separate ForceHotReload from UpdteFile to IDE, because they are 2 different operations and can both be done separately without invoking the other one. (cherry picked from commit e57aaf59a380829b83e49c2f99d81265b7c5e783) --- .../IDEChannel/ForceHotReloadIdeMessage.cs | 5 +- .../HotReloadRequestedIdeMessage.cs | 11 +- .../IDEChannel/IdeMessageWithCorrelationId.cs | 4 + .../IDEChannel/IdeResultMessage.cs | 5 + .../IDEChannel/UpdateFileIdeMessage.cs | 7 ++ .../HotReload/ServerHotReloadProcessor.cs | 110 ++++++++++------- src/Uno.UI.RemoteControl.VS/EntryPoint.cs | 114 ++++++++++-------- .../ClientHotReloadProcessor.ClientApi.cs | 2 +- .../HotReload/Messages/UpdateFile.cs | 2 +- 9 files changed, 154 insertions(+), 106 deletions(-) create mode 100644 src/Uno.UI.RemoteControl.Messaging/IDEChannel/IdeMessageWithCorrelationId.cs create mode 100644 src/Uno.UI.RemoteControl.Messaging/IDEChannel/IdeResultMessage.cs create mode 100644 src/Uno.UI.RemoteControl.Messaging/IDEChannel/UpdateFileIdeMessage.cs diff --git a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/ForceHotReloadIdeMessage.cs b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/ForceHotReloadIdeMessage.cs index 8b07f20ab546..90865fe38feb 100644 --- a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/ForceHotReloadIdeMessage.cs +++ b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/ForceHotReloadIdeMessage.cs @@ -4,7 +4,4 @@ namespace Uno.UI.RemoteControl.Messaging.IdeChannel; -public record ForceHotReloadIdeMessage( - long CorrelationId, - IReadOnlyDictionary? OptionalUpdatedFilesContent, - bool ForceFileSave = false) : IdeMessage(WellKnownScopes.HotReload); +public record ForceHotReloadIdeMessage(long CorrelationId) : IdeMessageWithCorrelationId(CorrelationId, WellKnownScopes.HotReload); diff --git a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/HotReloadRequestedIdeMessage.cs b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/HotReloadRequestedIdeMessage.cs index 30170a305b45..398be5f96d93 100644 --- a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/HotReloadRequestedIdeMessage.cs +++ b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/HotReloadRequestedIdeMessage.cs @@ -1,10 +1,3 @@ -#nullable enable +namespace Uno.UI.RemoteControl.Messaging.IdeChannel; -namespace Uno.UI.RemoteControl.Messaging.IdeChannel; - -/// -/// A message sent by the IDE to the dev-server to confirm a request has been processed. -/// -/// of the request. -/// Result of the request. -public record HotReloadRequestedIdeMessage(long RequestId, Result Result) : IdeMessage(WellKnownScopes.HotReload); +public record HotReloadRequestedIdeMessage(long IdeCorrelationId, Result Result) : IdeMessage(WellKnownScopes.HotReload); diff --git a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/IdeMessageWithCorrelationId.cs b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/IdeMessageWithCorrelationId.cs new file mode 100644 index 000000000000..8bf878b662c0 --- /dev/null +++ b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/IdeMessageWithCorrelationId.cs @@ -0,0 +1,4 @@ +#nullable enable +namespace Uno.UI.RemoteControl.Messaging.IdeChannel; + +public record IdeMessageWithCorrelationId(long CorrelationId, string Scope) : IdeMessage(Scope); diff --git a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/IdeResultMessage.cs b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/IdeResultMessage.cs new file mode 100644 index 000000000000..3601d205a9aa --- /dev/null +++ b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/IdeResultMessage.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace Uno.UI.RemoteControl.Messaging.IdeChannel; + +public record IdeResultMessage(long IdeCorrelationId, Result Result) : IdeMessage(WellKnownScopes.HotReload); diff --git a/src/Uno.UI.RemoteControl.Messaging/IDEChannel/UpdateFileIdeMessage.cs b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/UpdateFileIdeMessage.cs new file mode 100644 index 000000000000..382649b706cc --- /dev/null +++ b/src/Uno.UI.RemoteControl.Messaging/IDEChannel/UpdateFileIdeMessage.cs @@ -0,0 +1,7 @@ +namespace Uno.UI.RemoteControl.Messaging.IdeChannel; + +public record UpdateFileIdeMessage( + long CorrelationId, + string FileFullName, + string FileContent, + bool ForceSaveOnDisk) : IdeMessageWithCorrelationId(CorrelationId, WellKnownScopes.HotReload); diff --git a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs index ca50f25addc7..07cff185807e 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs +++ b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs @@ -47,6 +47,7 @@ private void InterpretMsbuildProperties(IDictionary properties) public async Task ProcessFrame(Frame frame) { + // Messages received from the CLIENT application switch (frame.Name) { case ConfigureServer.Name: @@ -64,14 +65,13 @@ public async Task ProcessFrame(Frame frame) /// public async Task ProcessIdeMessage(IdeMessage message, CancellationToken ct) { + // Messages received from the IDE switch (message) { - case HotReloadRequestedIdeMessage hrRequested: - // Note: For now the IDE will notify the ProcessingFiles only in case of force hot reload request sent by client! - await Notify(HotReloadEvent.ProcessingFiles, HotReloadEventSource.IDE); - if (_pendingHotReloadRequestToIde.TryGetValue(hrRequested.RequestId, out var request)) + case IdeResultMessage resultMessage: + if (_pendingRequestsToIde.TryGetValue(resultMessage.IdeCorrelationId, out var tcs)) { - request.TrySetResult(hrRequested.Result); + tcs.TrySetResult(resultMessage.Result); } break; @@ -348,7 +348,7 @@ private async ValueTask Complete(HotReloadServerResult result, Exception? except await Task.Delay(_noChangesRetryDelay); } - if (await _owner.RequestHotReloadToIde(Id)) + if (await _owner.RequestHotReloadToIde()) { return; } @@ -426,8 +426,39 @@ private void ProcessConfigureServer(ConfigureServer configureServer) } #endregion + #region SendAndWaitForResult + private readonly ConcurrentDictionary> _pendingRequestsToIde = new(); + + private long _lasIdeCorrelationId; + + private long GetNextIdeCorrelationId() => Interlocked.Increment(ref _lasIdeCorrelationId); + + private async Task SendAndWaitForResult(TMessage message) + where TMessage : IdeMessageWithCorrelationId + { + var tcs = new TaskCompletionSource(); + try + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await using var ctReg = cts.Token.Register(() => tcs.TrySetCanceled()); + + _pendingRequestsToIde.TryAdd(message.CorrelationId, tcs); + await _remoteControlServer.SendMessageToIDEAsync(message); + + return await tcs.Task; + } + catch (Exception ex) + { + return Result.Fail(ex); + } + finally + { + _pendingRequestsToIde.TryRemove(message.CorrelationId, out _); + } + } + #endregion + #region UpdateFile - private readonly ConcurrentDictionary> _pendingHotReloadRequestToIde = new(); private async Task ProcessUpdateFile(UpdateFile message) { @@ -438,23 +469,21 @@ private async Task ProcessUpdateFile(UpdateFile message) var (result, error) = message switch { { FilePath: null or { Length: 0 } } => (FileUpdateResult.BadRequest, "Invalid request (file path is empty)"), - // Right now, when running on VS, we're delegating the file update to the code that is running inside VS. - // we're not doing this for other file operations because they are not/less required for hot-reload. We may need to revisit this eventually. - { OldText: not null, NewText: not null } when !_isRunningInsideVisualStudio => await DoUpdate(message.OldText, message.NewText), - { OldText: null, NewText: not null } => await DoWrite(message.NewText), - { NewText: null, IsCreateDeleteAllowed: true } => await DoDelete(), + { OldText: not null, NewText: not null } => await (_isRunningInsideVisualStudio + ? DoRemoteUpdateInIde(message.NewText) + : DoUpdateOnDisk(message.OldText, message.NewText)), + { OldText: null, NewText: not null } => await (_isRunningInsideVisualStudio + ? DoRemoteUpdateInIde(message.NewText) + : DoWriteToDisk(message.NewText)), + { NewText: null, IsCreateDeleteAllowed: true } => await DoDeleteFromDisk(), _ => (FileUpdateResult.BadRequest, "Invalid request") }; + if ((int)result < 300 && !message.IsForceHotReloadDisabled) { hotReload.EnableAutoRetryIfNoChanges(message.ForceHotReloadAttempts, message.ForceHotReloadDelay); - var filesContent = - message is { FilePath: { Length: > 0 } f, NewText: { Length: > 0 } t } - ? new System.Collections.Generic.Dictionary { { f, t } } - : null; - var forceFileSaveInIde = _isRunningInsideVisualStudio && filesContent is not null; - await RequestHotReloadToIde(hotReload.Id, filesContent, forceFileSaveInIde); + await RequestHotReloadToIde(); } await _remoteControlServer.SendFrame(new UpdateFileResponse(message.RequestId, message.FilePath ?? "", result, error, hotReload.Id)); @@ -465,7 +494,21 @@ private async Task ProcessUpdateFile(UpdateFile message) await _remoteControlServer.SendFrame(new UpdateFileResponse(message.RequestId, message.FilePath ?? "", FileUpdateResult.Failed, ex.Message)); } - async ValueTask<(FileUpdateResult, string?)> DoUpdate(string oldText, string newText) + async Task<(FileUpdateResult, string?)> DoRemoteUpdateInIde(string newText) + { + var saveToDisk = message.ForceSaveOnDisk ?? true; // Temporary set to true until this issue is fixed: https://github.com/unoplatform/uno.hotdesign/issues/3454 + + // Right now, when running on VS, we're delegating the file update to the code that is running inside VS. + // we're not doing this for other file operations because they are not/less required for hot-reload. We may need to revisit this eventually. + var ideMsg = new UpdateFileIdeMessage(GetNextIdeCorrelationId(), message.FilePath, newText, saveToDisk); + var result = await SendAndWaitForResult(ideMsg); + + return result.IsSuccess + ? (FileUpdateResult.Success, null) + : (FileUpdateResult.Failed, result.Error); + } + + async Task<(FileUpdateResult, string?)> DoUpdateOnDisk(string oldText, string newText) { if (!File.Exists(message.FilePath)) { @@ -506,7 +549,7 @@ private async Task ProcessUpdateFile(UpdateFile message) return (FileUpdateResult.Success, null); } - async ValueTask<(FileUpdateResult, string?)> DoWrite(string newText) + async Task<(FileUpdateResult, string?)> DoWriteToDisk(string newText) { if (!message.IsCreateDeleteAllowed && !File.Exists(message.FilePath)) { @@ -530,7 +573,7 @@ private async Task ProcessUpdateFile(UpdateFile message) return (FileUpdateResult.Success, null); } - async ValueTask<(FileUpdateResult, string?)> DoDelete() + async ValueTask<(FileUpdateResult, string?)> DoDeleteFromDisk() { if (!File.Exists(message.FilePath)) { @@ -587,29 +630,14 @@ async Task WaitForFileUpdated() } } - private async Task RequestHotReloadToIde(long sequenceId, IReadOnlyDictionary? optionalUpdatedFilesContent = null, bool forceFileSave = false) + private async Task RequestHotReloadToIde() { - var hrRequest = new ForceHotReloadIdeMessage(sequenceId, optionalUpdatedFilesContent, forceFileSave); - var hrRequested = new TaskCompletionSource(); + var result = await SendAndWaitForResult(new ForceHotReloadIdeMessage(GetNextIdeCorrelationId())); - try - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - await using var ctReg = cts.Token.Register(() => hrRequested.TrySetCanceled()); - - _pendingHotReloadRequestToIde.TryAdd(hrRequest.CorrelationId, hrRequested); - await _remoteControlServer.SendMessageToIDEAsync(hrRequest); + // Note: For now the IDE will notify the ProcessingFiles only in case of force hot reload request sent by client! + await Notify(HotReloadEvent.ProcessingFiles, HotReloadEventSource.IDE); - return await hrRequested.Task is { IsSuccess: true }; - } - catch (Exception) - { - return false; - } - finally - { - _pendingHotReloadRequestToIde.TryRemove(hrRequest.CorrelationId, out _); - } + return result.IsSuccess; } #endregion diff --git a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs index 6e1d7fb443c1..dfd9d35b07ce 100644 --- a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs +++ b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs @@ -447,7 +447,10 @@ private async Task OnMessageReceivedAsync(object? sender, IdeMessage devServerMe await OnAddMenuItemRequestedAsync(sender, amir); break; case ForceHotReloadIdeMessage fhr: - await OnForceHotReloadRequestedAsync(sender, fhr); + await OnForceHotReloadRequestedAsync(fhr); + break; + case UpdateFileIdeMessage ufm: + await OnUpdateFileRequestedAsync(ufm); break; case NotificationRequestIdeMessage nr: await OnNotificationRequestedAsync(sender, nr); @@ -536,76 +539,87 @@ private async Task CreateInfoBarAsync(NotificationRequestIdeMessage e, IVsShell } } - private async Task OnForceHotReloadRequestedAsync(object? sender, ForceHotReloadIdeMessage request) + private async Task OnForceHotReloadRequestedAsync(ForceHotReloadIdeMessage request) { try { - if (request.OptionalUpdatedFilesContent is { Count: > 0 } fileUpdates) + // Programmatically trigger the "Apply Code Changes" command in Visual Studio. + // Which will trigger the hot reload. + _dte.ExecuteCommand("Debug.ApplyCodeChanges"); + + // Send a message back to indicate that the request has been received and acted upon. + if (_ideChannelClient is not null) { - foreach (var file in fileUpdates) - { - var filePath = file.Key; - var fileContent = file.Value; + await _ideChannelClient.SendToDevServerAsync(new IdeResultMessage(request.CorrelationId, Result.Success()), _ct.Token); + } + } + catch (Exception e) when (_ideChannelClient is not null) + { + await _ideChannelClient.SendToDevServerAsync(new IdeResultMessage(request.CorrelationId, Result.Fail(e)), _ct.Token); - if (fileContent is not { Length: > 0 }) - { - continue; // Skip empty content. - } + throw; + } + } - // Update the file content in the IDE using the DTE API. - var document = _dte2.Documents - .OfType() - .FirstOrDefault(d => AbsolutePathComparer.ComparerIgnoreCase.Equals(d.FullName, filePath)); + private async Task OnUpdateFileRequestedAsync(UpdateFileIdeMessage request) + { + try + { + if (request.FileContent is { Length: > 0 } fileContent) + { + var filePath = request.FileFullName; - var textDocument = document?.Object("TextDocument") as TextDocument; + // Update the file content in the IDE using the DTE API. + var document = _dte2.Documents + .OfType() + .FirstOrDefault(d => AbsolutePathComparer.ComparerIgnoreCase.Equals(d.FullName, filePath)); - if (textDocument is null) // The document is not open in the IDE, so we need to open it. - { - // Resolve the path to the document (in case it's not open in the IDE). - // The path may contain a mix of forward and backward slashes, so we normalize it by using Path.GetFullPath. - var adjustedPathForOpening = Path.GetFullPath(filePath); + var textDocument = document?.Object("TextDocument") as TextDocument; - document = _dte2.Documents.Open(adjustedPathForOpening); - textDocument = document?.Object("TextDocument") as TextDocument; - } + if (textDocument is null) // The document is not open in the IDE, so we need to open it. + { + // Resolve the path to the document (in case it's not open in the IDE). + // The path may contain a mix of forward and backward slashes, so we normalize it by using Path.GetFullPath. + var adjustedPathForOpening = Path.GetFullPath(filePath); - if (document is null || textDocument is null) - { - throw new InvalidOperationException($"Failed to open document {filePath}"); - } + document = _dte2.Documents.Open(adjustedPathForOpening); + textDocument = document?.Object("TextDocument") as TextDocument; + } - document.Activate(); // Sometimes the document is "soft closed", so we need to activate it for the user to see it. + if (document is null || textDocument is null) + { + throw new InvalidOperationException($"Failed to open document {filePath}"); + } - // Replace the content of the document with the new content. + document.Activate(); // Sometimes the document is "soft closed", so we need to activate it for the user to see it. - // Flags: 0b0000_0011 = vsEPReplaceTextOptions.vsEPReplaceTextKeepMarkers | vsEPReplaceTextOptions.vsEPReplaceTextNormalizeNewLines - // https://learn.microsoft.com/en-us/dotnet/api/envdte.vsepreplacetextoptions?view=visualstudiosdk-2022#fields - const int flags = 0b0000_0011; + // Replace the content of the document with the new content. - textDocument.StartPoint.CreateEditPoint() - .ReplaceText(textDocument.EndPoint, fileContent, flags); + // Flags: 0b0000_0011 = vsEPReplaceTextOptions.vsEPReplaceTextKeepMarkers | vsEPReplaceTextOptions.vsEPReplaceTextNormalizeNewLines + // https://learn.microsoft.com/en-us/dotnet/api/envdte.vsepreplacetextoptions?view=visualstudiosdk-2022#fields + const int flags = 0b0000_0011; - if (request.ForceFileSave) - { - // Save the document. - document.Save(); - } - } - } + textDocument.StartPoint.CreateEditPoint() + .ReplaceText(textDocument.EndPoint, fileContent, flags); - // Programmatically trigger the "Apply Code Changes" command in Visual Studio. - // Which will trigger the hot reload. - _dte.ExecuteCommand("Debug.ApplyCodeChanges"); + if (request.ForceSaveOnDisk) + { + // Save the document. + document.Save(); + } - // Send a message back to indicate that the request has been received and acted upon. - if (_ideChannelClient is not null) - { - await _ideChannelClient.SendToDevServerAsync(new HotReloadRequestedIdeMessage(request.CorrelationId, Result.Success()), _ct.Token); + // Send a message back to indicate that the request has been received and acted upon. + if (_ideChannelClient is not null) + { + await _ideChannelClient.SendToDevServerAsync( + new IdeResultMessage(request.CorrelationId, Result.Success()), _ct.Token); + } } } catch (Exception e) when (_ideChannelClient is not null) { - await _ideChannelClient.SendToDevServerAsync(new HotReloadRequestedIdeMessage(request.CorrelationId, Result.Fail(e)), _ct.Token); + // Send a message back to indicate that the request has failed. + await _ideChannelClient.SendToDevServerAsync(new IdeResultMessage(request.CorrelationId, Result.Fail(e)), _ct.Token); throw; } diff --git a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs index 9563dfb111f5..38a6b24e4488 100644 --- a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs +++ b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs @@ -40,7 +40,7 @@ public record struct UpdateRequest( string FilePath, string? OldText, string? NewText, - bool ForceSaveToDisk = true, // Temporary set to true until this issue is fixed: https://github.com/unoplatform/uno.hotdesign/issues/3454 + bool? ForceSaveToDisk = null, bool WaitForHotReload = true) { /// diff --git a/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs b/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs index 20a98af50b44..0ecf934b37de 100644 --- a/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs +++ b/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs @@ -38,7 +38,7 @@ public class UpdateFile : IMessage /// Currently, this is only used for VisualStudio, because the update requires a file save on disk for other IDEs. /// On VisualStudio, the save to disk is not required for doing Hot Reload. /// - public bool ForceSaveOnDisk { get; set; } + public bool? ForceSaveOnDisk { get; set; } /// /// Indicates if the file can be created or deleted. From cb1969d6b0eb3db509cbadbe77335af34278067f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 24 Feb 2025 20:43:47 -0500 Subject: [PATCH 5/8] ci: Fix build (cherry picked from commit afa8a3e3d9a3ca2a8923daff0fc9447dda9420d7) --- src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs | 1 + src/Uno.UI.Tests/Helpers/Given_AbsolutePathComparer.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs b/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs index 0ecf934b37de..f8e22b3e5eae 100644 --- a/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs +++ b/src/Uno.UI.RemoteControl/HotReload/Messages/UpdateFile.cs @@ -33,6 +33,7 @@ public class UpdateFile : IMessage /// /// If true, the file will be saved on disk, even if the content is the same. + /// NULL means the default behavior will be used according to the capabilities of the IDE (our integration). /// /// /// Currently, this is only used for VisualStudio, because the update requires a file save on disk for other IDEs. diff --git a/src/Uno.UI.Tests/Helpers/Given_AbsolutePathComparer.cs b/src/Uno.UI.Tests/Helpers/Given_AbsolutePathComparer.cs index 90321a5c8b1c..ec31a3af142c 100644 --- a/src/Uno.UI.Tests/Helpers/Given_AbsolutePathComparer.cs +++ b/src/Uno.UI.Tests/Helpers/Given_AbsolutePathComparer.cs @@ -2,6 +2,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using Uno.UI.Helpers; +#pragma warning disable IDE0055 // Fix formatting namespace Uno.UI.Tests.Helpers; [TestClass] From dc0f852c2d4592477090a38a3c00b95d4ee8d26e Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 25 Feb 2025 10:20:40 -0500 Subject: [PATCH 6/8] chore: Make the timeout waiting for VS update more appropriate for a file open + save operation (cherry picked from commit 555b0c9f8f51302944e4199ff49594ae94b06d2f) --- .../HotReload/ServerHotReloadProcessor.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs index 07cff185807e..a54ee7821983 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs +++ b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs @@ -26,6 +26,7 @@ namespace Uno.UI.RemoteControl.Host.HotReload { partial class ServerHotReloadProcessor : IServerProcessor, IDisposable { + private static readonly TimeSpan _waitForIdeResultTimeout = TimeSpan.FromSeconds(25); private readonly IRemoteControlServer _remoteControlServer; public ServerHotReloadProcessor(IRemoteControlServer remoteControlServer) @@ -439,7 +440,7 @@ private async Task SendAndWaitForResult(TMessage message) var tcs = new TaskCompletionSource(); try { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + using var cts = new CancellationTokenSource(_waitForIdeResultTimeout); await using var ctReg = cts.Token.Register(() => tcs.TrySetCanceled()); _pendingRequestsToIde.TryAdd(message.CorrelationId, tcs); @@ -622,7 +623,7 @@ async Task WaitForFileUpdated() }; watcher.EnableRaisingEvents = true; - if (await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(2))) != tcs.Task + if (await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(5))) != tcs.Task && this.Log().IsEnabled(LogLevel.Debug)) { this.Log().LogDebug($"File update event not received for '{message.FilePath}', continuing anyway [{message.RequestId}]."); From caede7e7d6fb6e8e0e5be5042f30c0a81cb3e3c2 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 25 Feb 2025 10:21:15 -0500 Subject: [PATCH 7/8] chore: Remove the requirement to open the file in VS. A _shadow opening_ is enough. (cherry picked from commit 51ce8a9de5d9e9398d6d28aafed9ff0abf8a43a9) --- src/Uno.UI.RemoteControl.VS/EntryPoint.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs index dfd9d35b07ce..a3686a82c64b 100644 --- a/src/Uno.UI.RemoteControl.VS/EntryPoint.cs +++ b/src/Uno.UI.RemoteControl.VS/EntryPoint.cs @@ -591,8 +591,6 @@ private async Task OnUpdateFileRequestedAsync(UpdateFileIdeMessage request) throw new InvalidOperationException($"Failed to open document {filePath}"); } - document.Activate(); // Sometimes the document is "soft closed", so we need to activate it for the user to see it. - // Replace the content of the document with the new content. // Flags: 0b0000_0011 = vsEPReplaceTextOptions.vsEPReplaceTextKeepMarkers | vsEPReplaceTextOptions.vsEPReplaceTextNormalizeNewLines From 9c9e4772f6c84481b7c91411ddc5e8ddb3bbdb2c Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 26 Feb 2025 01:00:26 -0500 Subject: [PATCH 8/8] chore: Fixed potential binary compatibility by adding a new parameter to a constructor. Should not happen anymore. (cherry picked from commit 8f9a71749d7ce294acc67367374b19358a89566b) --- .../HotReload/ServerHotReloadProcessor.cs | 9 +++++++-- .../HotReload/ClientHotReloadProcessor.ClientApi.cs | 12 ++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs index a54ee7821983..cd28b502c80e 100644 --- a/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs +++ b/src/Uno.UI.RemoteControl.Server.Processors/HotReload/ServerHotReloadProcessor.cs @@ -480,11 +480,16 @@ private async Task ProcessUpdateFile(UpdateFile message) _ => (FileUpdateResult.BadRequest, "Invalid request") }; - if ((int)result < 300 && !message.IsForceHotReloadDisabled) + var isIdeSupportingHotReload = _isRunningInsideVisualStudio; + + if (message.IsForceHotReloadDisabled is false && (int)result < 300) { hotReload.EnableAutoRetryIfNoChanges(message.ForceHotReloadAttempts, message.ForceHotReloadDelay); - await RequestHotReloadToIde(); + if (isIdeSupportingHotReload) + { + await RequestHotReloadToIde(); + } } await _remoteControlServer.SendFrame(new UpdateFileResponse(message.RequestId, message.FilePath ?? "", result, error, hotReload.Id)); diff --git a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs index 38a6b24e4488..15749f843edc 100644 --- a/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs +++ b/src/Uno.UI.RemoteControl/HotReload/ClientHotReloadProcessor.ClientApi.cs @@ -40,9 +40,17 @@ public record struct UpdateRequest( string FilePath, string? OldText, string? NewText, - bool? ForceSaveToDisk = null, bool WaitForHotReload = true) { + /// + /// Indicates if the file should be saved to disk. + /// + /// + /// Some IDE supports the ability to update the file in memory without saving it to disk. + /// Null means that the default behavior of the IDE should be used. + /// + public bool? ForceSaveToDisk { get; init; } + /// /// The max delay to wait for the server to process a file update request. /// @@ -97,7 +105,7 @@ public Task UpdateFileAsync(string filePath, string? oldText, string newText, bo => UpdateFileAsync(new UpdateRequest(filePath, oldText, newText, waitForHotReload), ct); public Task UpdateFileAsync(string filePath, string? oldText, string newText, bool waitForHotReload, bool forceSaveToDisk, CancellationToken ct) - => UpdateFileAsync(new UpdateRequest(filePath, oldText, newText, waitForHotReload, forceSaveToDisk), ct); + => UpdateFileAsync(new UpdateRequest(filePath, oldText, newText, waitForHotReload) { ForceSaveToDisk = forceSaveToDisk }, ct); public async Task UpdateFileAsync(UpdateRequest req, CancellationToken ct) {