diff --git a/Directory.Build.props b/Directory.Build.props index 3881fc20ecbb..e40a2279585b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -13,7 +13,6 @@ - true @@ -77,9 +76,7 @@ - false false - diff --git a/eng/Analyzers.props b/eng/Analyzers.props deleted file mode 100644 index 170e51092bb9..000000000000 --- a/eng/Analyzers.props +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/eng/Versions.props b/eng/Versions.props index cf8398468a07..eeff75233b8c 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -58,7 +58,6 @@ 2.0.3 13.0.3 4.8.6 - 1.2.0-beta.435 4.0.5 2.0.0-beta5.25279.2 1.1.2 diff --git a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs index fcf541045fc6..35b99dc1e437 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadClients.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadClients.cs @@ -187,39 +187,28 @@ public async ValueTask InitialUpdatesAppliedAsync(CancellationToken cancellation } /// Cancellation token. The cancellation should trigger on process terminatation. - public async Task ApplyStaticAssetUpdatesAsync(IEnumerable<(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject)> assets, CancellationToken cancellationToken) + public async Task ApplyStaticAssetUpdatesAsync(IEnumerable assets, CancellationToken cancellationToken) { if (browserRefreshServer != null) { - await browserRefreshServer.UpdateStaticAssetsAsync(assets.Select(static a => a.relativeUrl), cancellationToken); + await browserRefreshServer.UpdateStaticAssetsAsync(assets.Select(static a => a.RelativeUrl), cancellationToken); } else { var updates = new List(); - foreach (var (filePath, relativeUrl, assemblyName, isApplicationProject) in assets) + foreach (var asset in assets) { - ImmutableArray content; try { -#if NET - var blob = await File.ReadAllBytesAsync(filePath, cancellationToken); -#else - var blob = File.ReadAllBytes(filePath); -#endif - content = ImmutableCollectionsMarshal.AsImmutableArray(blob); + ClientLogger.LogDebug("Loading asset '{Url}' from '{Path}'.", asset.RelativeUrl, asset.FilePath); + updates.Add(await HotReloadStaticAssetUpdate.CreateAsync(asset, cancellationToken)); } catch (Exception e) when (e is not OperationCanceledException) { - ClientLogger.LogError("Failed to read file {FilePath}: {Message}", filePath, e.Message); + ClientLogger.LogError("Failed to read file {FilePath}: {Message}", asset.FilePath, e.Message); continue; } - - updates.Add(new HotReloadStaticAssetUpdate( - assemblyName: assemblyName, - relativePath: relativeUrl, - content: content, - isApplicationProject)); } await ApplyStaticAssetUpdatesAsync([.. updates], isProcessSuspended: false, cancellationToken); diff --git a/src/BuiltInTools/HotReloadClient/HotReloadStaticAssetUpdate.cs b/src/BuiltInTools/HotReloadClient/HotReloadStaticAssetUpdate.cs index 955e0cf07b78..51c33bae26cb 100644 --- a/src/BuiltInTools/HotReloadClient/HotReloadStaticAssetUpdate.cs +++ b/src/BuiltInTools/HotReloadClient/HotReloadStaticAssetUpdate.cs @@ -4,6 +4,10 @@ #nullable enable using System.Collections.Immutable; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; namespace Microsoft.DotNet.HotReload; @@ -13,4 +17,18 @@ internal readonly struct HotReloadStaticAssetUpdate(string assemblyName, string public string AssemblyName { get; } = assemblyName; public ImmutableArray Content { get; } = content; public bool IsApplicationProject { get; } = isApplicationProject; + + public static async ValueTask CreateAsync(StaticWebAsset asset, CancellationToken cancellationToken) + { +#if NET + var blob = await File.ReadAllBytesAsync(asset.FilePath, cancellationToken); +#else + var blob = File.ReadAllBytes(asset.FilePath); +#endif + return new HotReloadStaticAssetUpdate( + assemblyName: asset.AssemblyName, + relativePath: asset.RelativeUrl, + content: ImmutableCollectionsMarshal.AsImmutableArray(blob), + asset.IsApplicationProject); + } } diff --git a/src/BuiltInTools/HotReloadClient/Utilities/PathExtensions.cs b/src/BuiltInTools/HotReloadClient/Utilities/PathExtensions.cs new file mode 100644 index 000000000000..8e91575e0c82 --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Utilities/PathExtensions.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; + +namespace System.IO; + +internal static partial class PathExtensions +{ +#if NET // binary compatibility + public static bool IsPathFullyQualified(string path) + => Path.IsPathFullyQualified(path); + + public static string Join(string? path1, string? path2) + => Path.Join(path1, path2); +#else + extension(Path) + { + public static bool IsPathFullyQualified(string path) + => Path.DirectorySeparatorChar == '\\' + ? !IsPartiallyQualified(path.AsSpan()) + : Path.IsPathRooted(path); + } + + // Copied from https://github.com/dotnet/runtime/blob/a6c5ba30aab998555e36aec7c04311935e1797ab/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L250 + + /// + /// Returns true if the path specified is relative to the current drive or working directory. + /// Returns false if the path is fixed to a specific drive or UNC path. This method does no + /// validation of the path (URIs will be returned as relative as a result). + /// + /// + /// Handles paths that use the alternate directory separator. It is a frequent mistake to + /// assume that rooted paths (Path.IsPathRooted) are not relative. This isn't the case. + /// "C:a" is drive relative- meaning that it will be resolved against the current directory + /// for C: (rooted, but relative). "C:\a" is rooted and not relative (the current directory + /// will not be used to modify the path). + /// + private static bool IsPartiallyQualified(ReadOnlySpan path) + { + if (path.Length < 2) + { + // It isn't fixed, it must be relative. There is no way to specify a fixed + // path with one character (or less). + return true; + } + + if (IsDirectorySeparator(path[0])) + { + // There is no valid way to specify a relative path with two initial slashes or + // \? as ? isn't valid for drive relative paths and \??\ is equivalent to \\?\ + return !(path[1] == '?' || IsDirectorySeparator(path[1])); + } + + // The only way to specify a fixed path that doesn't begin with two slashes + // is the drive, colon, slash format- i.e. C:\ + return !((path.Length >= 3) + && (path[1] == Path.VolumeSeparatorChar) + && IsDirectorySeparator(path[2]) + // To match old behavior we'll check the drive character for validity as the path is technically + // not qualified if you don't have a valid drive. "=:\" is the "=" file's default data stream. + && IsValidDriveChar(path[0])); + } + + /// + /// True if the given character is a directory separator. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsDirectorySeparator(char c) + { + return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + } + + /// + /// Returns true if the given character is a valid drive letter + /// + internal static bool IsValidDriveChar(char value) + { + return (uint)((value | 0x20) - 'a') <= (uint)('z' - 'a'); + } + + // Copied from https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs + + private static readonly string s_directorySeparatorCharAsString = Path.DirectorySeparatorChar.ToString(); + + extension(Path) + { + public static string Join(string? path1, string? path2) + { + if (string.IsNullOrEmpty(path1)) + return path2 ?? string.Empty; + + if (string.IsNullOrEmpty(path2)) + return path1; + + return JoinInternal(path1, path2); + } + } + + private static string JoinInternal(string first, string second) + { + Debug.Assert(first.Length > 0 && second.Length > 0, "should have dealt with empty paths"); + + bool hasSeparator = IsDirectorySeparator(first[^1]) || IsDirectorySeparator(second[0]); + + return hasSeparator ? + string.Concat(first, second) : + string.Concat(first, s_directorySeparatorCharAsString, second); + } +#endif +} diff --git a/src/BuiltInTools/HotReloadClient/Web/StaticWebAsset.cs b/src/BuiltInTools/HotReloadClient/Web/StaticWebAsset.cs new file mode 100644 index 000000000000..578210ceb2c4 --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Web/StaticWebAsset.cs @@ -0,0 +1,63 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Immutable; +using System.IO; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.HotReload; + +internal readonly struct StaticWebAsset(string filePath, string relativeUrl, string assemblyName, bool isApplicationProject) +{ + public string FilePath => filePath; + public string RelativeUrl => relativeUrl; + public string AssemblyName => assemblyName; + public bool IsApplicationProject => isApplicationProject; + + public const string WebRoot = "wwwroot"; + public const string ManifestFileName = "staticwebassets.development.json"; + + public static bool IsScopedCssFile(string filePath) + => filePath.EndsWith(".razor.css", StringComparison.Ordinal) || + filePath.EndsWith(".cshtml.css", StringComparison.Ordinal); + + public static string GetScopedCssRelativeUrl(string applicationProjectFilePath, string containingProjectFilePath) + => WebRoot + "/" + GetScopedCssBundleFileName(applicationProjectFilePath, containingProjectFilePath); + + public static string GetScopedCssBundleFileName(string applicationProjectFilePath, string containingProjectFilePath) + { + var sourceProjectName = Path.GetFileNameWithoutExtension(containingProjectFilePath); + + return string.Equals(containingProjectFilePath, applicationProjectFilePath, StringComparison.OrdinalIgnoreCase) + ? $"{sourceProjectName}.styles.css" + : $"{sourceProjectName}.bundle.scp.css"; + } + + public static bool IsScopedCssBundleFile(string filePath) + => filePath.EndsWith(".bundle.scp.css", StringComparison.Ordinal) || + filePath.EndsWith(".styles.css", StringComparison.Ordinal) || + filePath.EndsWith(".bundle.scp.css.gz", StringComparison.Ordinal) || + filePath.EndsWith(".styles.css.gz", StringComparison.Ordinal); + + public static string? GetRelativeUrl(string applicationProjectFilePath, string containingProjectFilePath, string assetFilePath) + => IsScopedCssFile(assetFilePath) + ? GetScopedCssRelativeUrl(applicationProjectFilePath, containingProjectFilePath) + : GetAppRelativeUrlFomDiskPath(containingProjectFilePath, assetFilePath); + + /// + /// For non scoped css, the only static files which apply are the ones under the wwwroot folder in that project. The relative path + /// will always start with wwwroot. eg: "wwwroot/css/styles.css" + /// + public static string? GetAppRelativeUrlFomDiskPath(string containingProjectFilePath, string assetFilePath) + { + var webRoot = "wwwroot" + Path.DirectorySeparatorChar; + var webRootDir = Path.Combine(Path.GetDirectoryName(containingProjectFilePath)!, webRoot); + + return assetFilePath.StartsWith(webRootDir, StringComparison.OrdinalIgnoreCase) + ? assetFilePath.Substring(webRootDir.Length - webRoot.Length).Replace("\\", "/") + : null; + } +} diff --git a/src/BuiltInTools/HotReloadClient/Web/StaticWebAssetPattern.cs b/src/BuiltInTools/HotReloadClient/Web/StaticWebAssetPattern.cs new file mode 100644 index 000000000000..6e2bcf742265 --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Web/StaticWebAssetPattern.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +namespace Microsoft.DotNet.HotReload; + +internal sealed partial class StaticWebAssetPattern(string directory, string pattern, string baseUrl) +{ + public string Directory { get; } = directory; + public string Pattern { get; } = pattern; + public string BaseUrl { get; } = baseUrl; +} diff --git a/src/BuiltInTools/HotReloadClient/Web/StaticWebAssetsManifest.cs b/src/BuiltInTools/HotReloadClient/Web/StaticWebAssetsManifest.cs new file mode 100644 index 000000000000..22a1c4d5e0a5 --- /dev/null +++ b/src/BuiltInTools/HotReloadClient/Web/StaticWebAssetsManifest.cs @@ -0,0 +1,210 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.HotReload; + +internal sealed class StaticWebAssetsManifest(ImmutableDictionary urlToPathMap, ImmutableArray discoveryPatterns) +{ + private static readonly JsonSerializerOptions s_options = new() + { + RespectNullableAnnotations = true, + }; + + private sealed class ManifestJson + { + public required List ContentRoots { get; init; } + public required ChildAssetJson Root { get; init; } + } + + private sealed class ChildAssetJson + { + public Dictionary? Children { get; init; } + public AssetInfoJson? Asset { get; init; } + public List? Patterns { get; init; } + } + + private sealed class AssetInfoJson + { + public required int ContentRootIndex { get; init; } + public required string SubPath { get; init; } + } + + private sealed class PatternJson + { + public required int ContentRootIndex { get; init; } + public required string Pattern { get; init; } + } + + /// + /// Maps relative URLs to file system paths. + /// + public ImmutableDictionary UrlToPathMap { get; } = urlToPathMap; + + /// + /// List of directory and search pattern pairs for discovering static web assets. + /// + public ImmutableArray DiscoveryPatterns { get; } = discoveryPatterns; + + public bool TryGetBundleFilePath(string bundleFileName, [NotNullWhen(true)] out string? filePath) + { + if (UrlToPathMap.TryGetValue(bundleFileName, out var bundleFilePath)) + { + filePath = bundleFilePath; + return true; + } + + foreach (var entry in UrlToPathMap) + { + var url = entry.Key; + var path = entry.Value; + + if (Path.GetFileName(path).Equals(bundleFileName, StringComparison.Ordinal)) + { + filePath = path; + return true; + } + } + + filePath = null; + return false; + } + + public static StaticWebAssetsManifest? TryParseFile(string path, ILogger logger) + { + Stream? stream; + + logger.LogDebug("Reading static web assets manifest file: '{FilePath}'.", path); + + try + { + stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + } + catch (Exception e) + { + logger.LogError("Failed to read '{FilePath}': {Message}", path, e.Message); + return null; + } + + try + { + return TryParse(stream, path, logger); + } + finally + { + stream.Dispose(); + } + } + + /// The format is invalid. + public static StaticWebAssetsManifest? TryParse(Stream stream, string filePath, ILogger logger) + { + ManifestJson? manifest; + + try + { + manifest = JsonSerializer.Deserialize(stream, s_options); + } + catch (JsonException e) + { + logger.LogError("Failed to parse '{FilePath}': {Message}", filePath, e.Message); + return null; + } + + if (manifest == null) + { + logger.LogError("Failed to parse '{FilePath}'", filePath); + return null; + } + + var validContentRoots = new string?[manifest.ContentRoots.Count]; + + for (var i = 0; i < validContentRoots.Length; i++) + { + var root = manifest.ContentRoots[i]; + if (Path.IsPathFullyQualified(root)) + { + validContentRoots[i] = root; + } + else + { + logger.LogWarning("Failed to parse '{FilePath}': ContentRoots path not fully qualified: {Root}", filePath, root); + } + } + + var urlToPathMap = ImmutableDictionary.CreateBuilder(); + var discoveryPatterns = ImmutableArray.CreateBuilder(); + + ProcessNode(manifest.Root, url: ""); + + return new StaticWebAssetsManifest(urlToPathMap.ToImmutable(), discoveryPatterns.ToImmutable()); + + void ProcessNode(ChildAssetJson node, string url) + { + if (node.Children != null) + { + foreach (var entry in node.Children) + { + var childName = entry.Key; + var child = entry.Value; + + ProcessNode(child, url: (url is []) ? childName : url + "/" + childName); + } + } + + if (node.Asset != null) + { + if (url == "") + { + logger.LogWarning("Failed to parse '{FilePath}': Asset has no URL", filePath); + return; + } + + if (!TryGetContentRoot(node.Asset.ContentRootIndex, out var root)) + { + return; + } + + urlToPathMap[url] = Path.Join(root, node.Asset.SubPath.Replace('/', Path.DirectorySeparatorChar)); + } + else if (node.Children == null) + { + logger.LogWarning("Failed to parse '{FilePath}': Missing Asset", filePath); + } + + if (node.Patterns != null) + { + foreach (var pattern in node.Patterns) + { + if (TryGetContentRoot(pattern.ContentRootIndex, out var root)) + { + discoveryPatterns.Add(new StaticWebAssetPattern(root, pattern.Pattern, url)); + } + } + } + + bool TryGetContentRoot(int index, [NotNullWhen(true)] out string? contentRoot) + { + if (index < 0 || index >= validContentRoots.Length) + { + logger.LogWarning("Failed to parse '{FilePath}': Invalid value of ContentRootIndex: {Value}", filePath, index); + contentRoot = null; + return false; + } + + contentRoot = validContentRoots[index]; + return contentRoot != null; + } + } + } +} diff --git a/src/BuiltInTools/Watch.Aspire/DotNetWatchLauncher.cs b/src/BuiltInTools/Watch.Aspire/DotNetWatchLauncher.cs index 6a796e464d79..0bae06178441 100644 --- a/src/BuiltInTools/Watch.Aspire/DotNetWatchLauncher.cs +++ b/src/BuiltInTools/Watch.Aspire/DotNetWatchLauncher.cs @@ -40,6 +40,9 @@ public static async Task RunAsync(string workingDirectory, DotNetWatchOpti var muxerPath = Path.GetFullPath(Path.Combine(options.SdkDirectory, "..", "..", "dotnet" + PathUtilities.ExecutableExtension)); + // msbuild tasks depend on host path variable: + Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, muxerPath); + var console = new PhysicalConsole(TestFlags.None); var reporter = new ConsoleReporter(console, suppressEmojis: false); var environmentOptions = EnvironmentOptions.FromEnvironment(muxerPath); diff --git a/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs b/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs index de999b11e0a5..74ce7a91dde2 100644 --- a/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs +++ b/src/BuiltInTools/Watch/Browser/BrowserLauncher.cs @@ -39,7 +39,7 @@ public void InstallBrowserLaunchTrigger( WebServerProcessStateObserver.Observe(projectNode, processSpec, url => { if (projectOptions.IsRootProject && - ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), projectNode.GetProjectInstanceId())) + ImmutableInterlocked.Update(ref _browserLaunchAttempted, static (set, key) => set.Add(key), projectNode.ProjectInstance.GetId())) { // first build iteration of a root project: var launchUrl = GetLaunchUrl(launchProfile.LaunchUrl, url); diff --git a/src/BuiltInTools/Watch/Browser/BrowserRefreshServerFactory.cs b/src/BuiltInTools/Watch/Browser/BrowserRefreshServerFactory.cs index ea99bd9a22d4..5c6e2e9f4bef 100644 --- a/src/BuiltInTools/Watch/Browser/BrowserRefreshServerFactory.cs +++ b/src/BuiltInTools/Watch/Browser/BrowserRefreshServerFactory.cs @@ -45,7 +45,7 @@ public void Dispose() BrowserRefreshServer? server; bool hasExistingServer; - var key = projectNode.GetProjectInstanceId(); + var key = projectNode.ProjectInstance.GetId(); lock (_serversGuard) { @@ -74,7 +74,7 @@ public void Dispose() public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)] out BrowserRefreshServer? server) { - var key = projectNode.GetProjectInstanceId(); + var key = projectNode.ProjectInstance.GetId(); lock (_serversGuard) { diff --git a/src/BuiltInTools/Watch/Build/BuildNames.cs b/src/BuiltInTools/Watch/Build/BuildNames.cs index f5f8c3463a3f..0b2b8d0ccd00 100644 --- a/src/BuiltInTools/Watch/Build/BuildNames.cs +++ b/src/BuiltInTools/Watch/Build/BuildNames.cs @@ -18,8 +18,6 @@ internal static class PropertyNames public const string HotReloadAutoRestart = nameof(HotReloadAutoRestart); public const string DefaultItemExcludes = nameof(DefaultItemExcludes); public const string CustomCollectWatchItems = nameof(CustomCollectWatchItems); - public const string UsingMicrosoftNETSdkRazor = nameof(UsingMicrosoftNETSdkRazor); - public const string DotNetWatchContentFiles = nameof(DotNetWatchContentFiles); public const string DotNetWatchBuild = nameof(DotNetWatchBuild); public const string DesignTimeBuild = nameof(DesignTimeBuild); public const string SkipCompilerExecution = nameof(SkipCompilerExecution); @@ -33,18 +31,24 @@ internal static class ItemNames public const string Compile = nameof(Compile); public const string Content = nameof(Content); public const string ProjectCapability = nameof(ProjectCapability); + public const string ScopedCssInput = nameof(ScopedCssInput); + public const string StaticWebAssetEndpoint = nameof(StaticWebAssetEndpoint); } internal static class MetadataNames { - public const string Watch = nameof(Watch); public const string TargetPath = nameof(TargetPath); + public const string AssetFile = nameof(AssetFile); + public const string EndpointProperties = nameof(EndpointProperties); } internal static class TargetNames { public const string Compile = nameof(Compile); + public const string CompileDesignTime = nameof(CompileDesignTime); public const string Restore = nameof(Restore); + public const string ResolveScopedCssInputs = nameof(ResolveScopedCssInputs); + public const string ResolveReferencedProjectsStaticWebAssets = nameof(ResolveReferencedProjectsStaticWebAssets); public const string GenerateComputedBuildStaticWebAssets = nameof(GenerateComputedBuildStaticWebAssets); public const string ReferenceCopyLocalPathsOutputGroup = nameof(ReferenceCopyLocalPathsOutputGroup); } diff --git a/src/BuiltInTools/Watch/Build/BuildReporter.cs b/src/BuiltInTools/Watch/Build/BuildReporter.cs index f062e112d457..6410476dc3c8 100644 --- a/src/BuiltInTools/Watch/Build/BuildReporter.cs +++ b/src/BuiltInTools/Watch/Build/BuildReporter.cs @@ -23,12 +23,12 @@ public void ReportWatchedFiles(Dictionary fileItems) { logger.Log(MessageDescriptor.WatchingFilesForChanges, fileItems.Count); - if (environmentOptions.TestFlags.HasFlag(TestFlags.RunningAsTest)) + if (logger.IsEnabled(LogLevel.Trace)) { foreach (var file in fileItems.Values) { - logger.Log(MessageDescriptor.WatchingFilesForChanges_FilePath, file.StaticWebAssetPath != null - ? $"{file.FilePath}{Path.PathSeparator}{file.StaticWebAssetPath}" + logger.Log(MessageDescriptor.WatchingFilesForChanges_FilePath, file.StaticWebAssetRelativeUrl != null + ? $"{file.FilePath}{Path.PathSeparator}{string.Join(Path.PathSeparator, file.StaticWebAssetRelativeUrl)}" : $"{file.FilePath}"); } } diff --git a/src/BuiltInTools/Watch/Build/EvaluationResult.cs b/src/BuiltInTools/Watch/Build/EvaluationResult.cs index bb04a3683a7e..d19b674329cd 100644 --- a/src/BuiltInTools/Watch/Build/EvaluationResult.cs +++ b/src/BuiltInTools/Watch/Build/EvaluationResult.cs @@ -2,12 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Immutable; +using Microsoft.Build.Execution; using Microsoft.Build.Graph; +using Microsoft.DotNet.HotReload; using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watch; -internal sealed class EvaluationResult(IReadOnlyDictionary files, ProjectGraph projectGraph) +internal sealed class EvaluationResult(ProjectGraph projectGraph, IReadOnlyDictionary files, IReadOnlyDictionary staticWebAssetsManifests) { public readonly IReadOnlyDictionary Files = files; public readonly ProjectGraph ProjectGraph = projectGraph; @@ -26,9 +28,17 @@ private static IReadOnlySet CreateBuildFileSet(ProjectGraph projectGraph public IReadOnlySet BuildFiles => _lazyBuildFiles.Value; + public IReadOnlyDictionary StaticWebAssetsManifests + => staticWebAssetsManifests; + public void WatchFiles(FileWatcher fileWatcher) { fileWatcher.WatchContainingDirectories(Files.Keys, includeSubdirectories: true); + + fileWatcher.WatchContainingDirectories( + StaticWebAssetsManifests.Values.SelectMany(static manifest => manifest.DiscoveryPatterns.Select(static pattern => pattern.Directory)), + includeSubdirectories: true); + fileWatcher.WatchFiles(BuildFiles); } @@ -41,9 +51,7 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera .SetItem(PropertyNames.DotNetWatchBuild, "true") .SetItem(PropertyNames.DesignTimeBuild, "true") .SetItem(PropertyNames.SkipCompilerExecution, "true") - .SetItem(PropertyNames.ProvideCommandLineArgs, "true") - // F# targets depend on host path variable: - .SetItem("DOTNET_HOST_PATH", environmentOptions.MuxerPath); + .SetItem(PropertyNames.ProvideCommandLineArgs, "true"); } /// @@ -87,6 +95,7 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera } var fileItems = new Dictionary(); + var staticWebAssetManifests = new Dictionary(); foreach (var project in projectGraph.ProjectNodesTopologicallySorted) { @@ -101,11 +110,15 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera continue; } - var customCollectWatchItems = projectInstance.GetStringListPropertyValue(PropertyNames.CustomCollectWatchItems); + var targets = GetBuildTargets(projectInstance, environmentOptions); + if (targets is []) + { + continue; + } using (var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "DesignTimeBuild")) { - if (!projectInstance.Build([TargetNames.Compile, .. customCollectWatchItems], loggers)) + if (!projectInstance.Build(targets, loggers)) { logger.LogError("Failed to build project '{Path}'.", projectInstance.FullPath); loggers.ReportOutput(); @@ -116,36 +129,46 @@ public static ImmutableDictionary GetGlobalBuildOptions(IEnumera var projectPath = projectInstance.FullPath; var projectDirectory = Path.GetDirectoryName(projectPath)!; - // TODO: Compile and AdditionalItems should be provided by Roslyn - var items = projectInstance.GetItems(ItemNames.Compile) - .Concat(projectInstance.GetItems(ItemNames.AdditionalFiles)) - .Concat(projectInstance.GetItems(ItemNames.Watch)); - - foreach (var item in items) + if (targets.Contains(TargetNames.GenerateComputedBuildStaticWebAssets) && + projectInstance.GetIntermediateOutputDirectory() is { } outputDir && + StaticWebAssetsManifest.TryParseFile(Path.Combine(outputDir, StaticWebAsset.ManifestFileName), logger) is { } manifest) { - AddFile(item.EvaluatedInclude, staticWebAssetPath: null); - } + staticWebAssetManifests.Add(projectInstance.GetId(), manifest); - if (!environmentOptions.SuppressHandlingStaticContentFiles && - projectInstance.GetBooleanPropertyValue(PropertyNames.UsingMicrosoftNETSdkRazor) && - projectInstance.GetBooleanPropertyValue(PropertyNames.DotNetWatchContentFiles, defaultValue: true)) - { - foreach (var item in projectInstance.GetItems(ItemNames.Content)) + // watch asset files, but not bundle files as they are regenarated when scoped CSS files are updated: + foreach (var (relativeUrl, filePath) in manifest.UrlToPathMap) { - if (item.GetBooleanMetadataValue(MetadataNames.Watch, defaultValue: true)) + if (!StaticWebAsset.IsScopedCssBundleFile(filePath)) { - var relativeUrl = item.EvaluatedInclude.Replace('\\', '/'); - if (relativeUrl.StartsWith("wwwroot/")) - { - AddFile(item.EvaluatedInclude, staticWebAssetPath: relativeUrl); - } + AddFile(filePath, staticWebAssetRelativeUrl: relativeUrl); } } } - void AddFile(string include, string? staticWebAssetPath) + // Adds file items for scoped css files. + // Scoped css files are bundled into a single entry per project that is represented in the static web assets manifest, + // but we need to watch the original individual files. + if (targets.Contains(TargetNames.ResolveScopedCssInputs)) { - var filePath = Path.GetFullPath(Path.Combine(projectDirectory, include)); + foreach (var item in projectInstance.GetItems(ItemNames.ScopedCssInput)) + { + AddFile(item.EvaluatedInclude, staticWebAssetRelativeUrl: null); + } + } + + // Add Watch items after other items so that we don't override properties set above. + var items = projectInstance.GetItems(ItemNames.Compile) + .Concat(projectInstance.GetItems(ItemNames.AdditionalFiles)) + .Concat(projectInstance.GetItems(ItemNames.Watch)); + + foreach (var item in items) + { + AddFile(item.EvaluatedInclude, staticWebAssetRelativeUrl: null); + } + + void AddFile(string relativePath, string? staticWebAssetRelativeUrl) + { + var filePath = Path.GetFullPath(Path.Combine(projectDirectory, relativePath)); if (!fileItems.TryGetValue(filePath, out var existingFile)) { @@ -153,7 +176,7 @@ void AddFile(string include, string? staticWebAssetPath) { FilePath = filePath, ContainingProjectPaths = [projectPath], - StaticWebAssetPath = staticWebAssetPath, + StaticWebAssetRelativeUrl = staticWebAssetRelativeUrl, }); } else if (!existingFile.ContainingProjectPaths.Contains(projectPath)) @@ -166,6 +189,43 @@ void AddFile(string include, string? staticWebAssetPath) buildReporter.ReportWatchedFiles(fileItems); - return new EvaluationResult(fileItems, projectGraph); + return new EvaluationResult(projectGraph, fileItems, staticWebAssetManifests); + } + + private static string[] GetBuildTargets(ProjectInstance projectInstance, EnvironmentOptions environmentOptions) + { + var compileTarget = projectInstance.Targets.ContainsKey(TargetNames.CompileDesignTime) + ? TargetNames.CompileDesignTime + : projectInstance.Targets.ContainsKey(TargetNames.Compile) + ? TargetNames.Compile + : null; + + if (compileTarget == null) + { + return []; + } + + var targets = new List + { + compileTarget + }; + + if (!environmentOptions.SuppressHandlingStaticContentFiles) + { + // generates static file asset manifest + if (projectInstance.Targets.ContainsKey(TargetNames.GenerateComputedBuildStaticWebAssets)) + { + targets.Add(TargetNames.GenerateComputedBuildStaticWebAssets); + } + + // populates ScopedCssInput items: + if (projectInstance.Targets.ContainsKey(TargetNames.ResolveScopedCssInputs)) + { + targets.Add(TargetNames.ResolveScopedCssInputs); + } + } + + targets.AddRange(projectInstance.GetStringListPropertyValue(PropertyNames.CustomCollectWatchItems)); + return [.. targets]; } } diff --git a/src/BuiltInTools/Watch/Build/FileItem.cs b/src/BuiltInTools/Watch/Build/FileItem.cs index acf044055fae..bd6719f2ca00 100644 --- a/src/BuiltInTools/Watch/Build/FileItem.cs +++ b/src/BuiltInTools/Watch/Build/FileItem.cs @@ -14,8 +14,6 @@ internal readonly record struct FileItem /// public required List ContainingProjectPaths { get; init; } - public string? StaticWebAssetPath { get; init; } - - public bool IsStaticFile => StaticWebAssetPath != null; + public string? StaticWebAssetRelativeUrl { get; init; } } } diff --git a/src/BuiltInTools/Watch/Build/FilePathExclusions.cs b/src/BuiltInTools/Watch/Build/FilePathExclusions.cs index 305ef3c0ab9e..b4f921d875f6 100644 --- a/src/BuiltInTools/Watch/Build/FilePathExclusions.cs +++ b/src/BuiltInTools/Watch/Build/FilePathExclusions.cs @@ -40,7 +40,7 @@ public static FilePathExclusions Create(ProjectGraph projectGraph) // If default items are not enabled exclude just the output directories. TryAddOutputDir(projectNode.GetOutputDirectory()); - TryAddOutputDir(projectNode.GetIntermediateOutputDirectory()); + TryAddOutputDir(projectNode.ProjectInstance.GetIntermediateOutputDirectory()); void TryAddOutputDir(string? dir) { diff --git a/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs b/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs index 40314297b76a..111dff988e47 100644 --- a/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs +++ b/src/BuiltInTools/Watch/Build/ProjectGraphUtilities.cs @@ -10,13 +10,16 @@ namespace Microsoft.DotNet.Watch; internal static class ProjectGraphUtilities { public static string GetDisplayName(this ProjectGraphNode projectNode) - => $"{Path.GetFileNameWithoutExtension(projectNode.ProjectInstance.FullPath)} ({projectNode.GetTargetFramework()})"; + => projectNode.ProjectInstance.GetDisplayName(); - public static string GetTargetFramework(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetFramework); + public static string GetDisplayName(this ProjectInstance project) + => $"{Path.GetFileNameWithoutExtension(project.FullPath)} ({project.GetTargetFramework()})"; - public static IEnumerable GetTargetFrameworks(this ProjectGraphNode projectNode) - => projectNode.GetStringListPropertyValue(PropertyNames.TargetFrameworks); + public static string GetTargetFramework(this ProjectInstance project) + => project.GetPropertyValue(PropertyNames.TargetFramework); + + public static IEnumerable GetTargetFrameworks(this ProjectInstance project) + => project.GetStringListPropertyValue(PropertyNames.TargetFrameworks); public static Version? GetTargetFrameworkVersion(this ProjectGraphNode projectNode) { @@ -54,8 +57,8 @@ public static bool IsWebApp(this ProjectGraphNode projectNode) public static string GetAssemblyName(this ProjectGraphNode projectNode) => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.TargetName); - public static string? GetIntermediateOutputDirectory(this ProjectGraphNode projectNode) - => projectNode.ProjectInstance.GetPropertyValue(PropertyNames.IntermediateOutputPath) is { Length: >0 } path ? Path.Combine(projectNode.ProjectInstance.Directory, path) : null; + public static string? GetIntermediateOutputDirectory(this ProjectInstance project) + => project.GetPropertyValue(PropertyNames.IntermediateOutputPath) is { Length: >0 } path ? Path.Combine(project.Directory, path) : null; public static IEnumerable GetCapabilities(this ProjectGraphNode projectNode) => projectNode.ProjectInstance.GetItems(ItemNames.ProjectCapability).Select(item => item.EvaluatedInclude); @@ -120,6 +123,6 @@ private static IEnumerable GetTransitiveProjects(IEnumerable

new(projectNode.ProjectInstance.FullPath, projectNode.GetTargetFramework()); + public static ProjectInstanceId GetId(this ProjectInstance project) + => new(project.FullPath, project.GetTargetFramework()); } diff --git a/src/BuiltInTools/Watch/Build/StaticWebAssetPattern.MSBuild.cs b/src/BuiltInTools/Watch/Build/StaticWebAssetPattern.MSBuild.cs new file mode 100644 index 000000000000..35931038b8ec --- /dev/null +++ b/src/BuiltInTools/Watch/Build/StaticWebAssetPattern.MSBuild.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Build.Globbing; + +namespace Microsoft.DotNet.HotReload; + +internal sealed partial class StaticWebAssetPattern +{ + public MSBuildGlob Glob => field ??= MSBuildGlob.Parse(Directory, Pattern); +} diff --git a/src/BuiltInTools/Watch/Context/EnvironmentVariables.cs b/src/BuiltInTools/Watch/Context/EnvironmentVariables.cs index 41ac7d88edea..f54743d89eef 100644 --- a/src/BuiltInTools/Watch/Context/EnvironmentVariables.cs +++ b/src/BuiltInTools/Watch/Context/EnvironmentVariables.cs @@ -13,6 +13,7 @@ public static class Names public const string DotnetWatchIteration = "DOTNET_WATCH_ITERATION"; public const string DotnetLaunchProfile = "DOTNET_LAUNCH_PROFILE"; + public const string DotnetHostPath = "DOTNET_HOST_PATH"; public const string DotNetWatchHotReloadNamedPipeName = HotReload.AgentEnvironmentVariables.DotNetWatchHotReloadNamedPipeName; public const string DotNetStartupHooks = HotReload.AgentEnvironmentVariables.DotNetStartupHooks; diff --git a/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs b/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs index 43d60c17aefe..3f5bff195e92 100644 --- a/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs +++ b/src/BuiltInTools/Watch/FileWatcher/FileWatcher.cs @@ -44,7 +44,7 @@ protected virtual DirectoryWatcher CreateDirectoryWatcher(string directory, Immu var watcher = DirectoryWatcher.Create(directory, fileNames, environmentOptions.IsPollingEnabled, includeSubdirectories); if (watcher is EventBasedDirectoryWatcher eventBasedWatcher) { - eventBasedWatcher.Logger = message => logger.LogDebug(message); + eventBasedWatcher.Logger = message => logger.LogTrace(message); } return watcher; diff --git a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs index e95ccc1be34f..a8f73dbb48df 100644 --- a/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/Watch/HotReload/CompilationHandler.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using System.Diagnostics; +using Microsoft.Build.Execution; using Microsoft.Build.Graph; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -15,9 +16,8 @@ namespace Microsoft.DotNet.Watch internal sealed class CompilationHandler : IDisposable { public readonly IncrementalMSBuildWorkspace Workspace; - private readonly ILogger _logger; + private readonly DotNetWatchContext _context; private readonly HotReloadService _hotReloadService; - private readonly ProcessRunner _processRunner; ///

/// Lock to synchronize: @@ -39,11 +39,10 @@ internal sealed class CompilationHandler : IDisposable private bool _isDisposed; - public CompilationHandler(ILogger logger, ProcessRunner processRunner) + public CompilationHandler(DotNetWatchContext context) { - _logger = logger; - _processRunner = processRunner; - Workspace = new IncrementalMSBuildWorkspace(logger); + _context = context; + Workspace = new IncrementalMSBuildWorkspace(context.Logger); _hotReloadService = new HotReloadService(Workspace.CurrentSolution.Services, () => ValueTask.FromResult(GetAggregateCapabilities())); } @@ -53,9 +52,12 @@ public void Dispose() Workspace?.Dispose(); } + private ILogger Logger + => _context.Logger; + public async ValueTask TerminateNonRootProcessesAndDispose(CancellationToken cancellationToken) { - _logger.LogDebug("Terminating remaining child processes."); + Logger.LogDebug("Terminating remaining child processes."); await TerminateNonRootProcessesAsync(projectPaths: null, cancellationToken); Dispose(); } @@ -75,7 +77,7 @@ private void DiscardPreviousUpdates(ImmutableArray projectsToBeRebuil } public async ValueTask StartSessionAsync(CancellationToken cancellationToken) { - _logger.Log(MessageDescriptor.HotReloadSessionStarting); + Logger.Log(MessageDescriptor.HotReloadSessionStarting); var solution = Workspace.CurrentSolution; @@ -95,7 +97,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) } } - _logger.Log(MessageDescriptor.HotReloadSessionStarted); + Logger.Log(MessageDescriptor.HotReloadSessionStarted); } public async Task TrackRunningProjectAsync( @@ -140,7 +142,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) }; var launchResult = new ProcessLaunchResult(); - var runningProcess = _processRunner.RunAsync(processSpec, clients.ClientLogger, launchResult, processTerminationSource.Token); + var runningProcess = _context.ProcessRunner.RunAsync(processSpec, clients.ClientLogger, launchResult, processTerminationSource.Token); if (launchResult.ProcessId == null) { // error already reported @@ -232,7 +234,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) catch (OperationCanceledException) when (processExitedSource.IsCancellationRequested) { // Process exited during initialization. This should not happen since we control the process during this time. - _logger.LogError("Failed to launch '{ProjectPath}'. Process {PID} exited during initialization.", projectPath, launchResult.ProcessId); + Logger.LogError("Failed to launch '{ProjectPath}'. Process {PID} exited during initialization.", projectPath, launchResult.ProcessId); return null; } } @@ -281,7 +283,7 @@ private ImmutableArray GetAggregateCapabilities() .Order() .ToImmutableArray(); - _logger.Log(MessageDescriptor.HotReloadCapabilities, string.Join(" ", capabilities)); + Logger.Log(MessageDescriptor.HotReloadCapabilities, string.Join(" ", capabilities)); return capabilities; } @@ -341,7 +343,7 @@ private static void PrepareCompilations(Solution solution, string projectPath, C { _hotReloadService.DiscardUpdate(); - _logger.Log(MessageDescriptor.HotReloadSuspended); + Logger.Log(MessageDescriptor.HotReloadSuspended); await Task.Delay(-1, cancellationToken); return ([], [], [], []); @@ -409,7 +411,7 @@ await ForEachProjectAsync(projectsToUpdate, async (runningProject, cancellationT return projectsWithPath[0]; } - return projectsWithPath.SingleOrDefault(p => string.Equals(p.ProjectNode.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase)); + return projectsWithPath.SingleOrDefault(p => string.Equals(p.ProjectNode.ProjectInstance.GetTargetFramework(), tfm, StringComparison.OrdinalIgnoreCase)); } private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, ImmutableDictionary runningProjectInfos, CancellationToken cancellationToken) @@ -420,11 +422,11 @@ private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, Im break; case HotReloadService.Status.NoChangesToApply: - _logger.Log(MessageDescriptor.NoCSharpChangesToApply); + Logger.Log(MessageDescriptor.NoCSharpChangesToApply); break; case HotReloadService.Status.Blocked: - _logger.Log(MessageDescriptor.UnableToApplyChanges); + Logger.Log(MessageDescriptor.UnableToApplyChanges); break; default: @@ -433,7 +435,7 @@ private async ValueTask DisplayResultsAsync(HotReloadService.Updates updates, Im if (!updates.ProjectsToRestart.IsEmpty) { - _logger.Log(MessageDescriptor.RestartNeededToApplyChanges); + Logger.Log(MessageDescriptor.RestartNeededToApplyChanges); } var errorsToDisplayInApp = new List(); @@ -515,7 +517,7 @@ void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor, strin var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic); var args = new[] { autoPrefix, display }; - _logger.Log(descriptor, args); + Logger.Log(descriptor, args); if (autoPrefix != "") { @@ -551,19 +553,27 @@ static MessageDescriptor GetMessageDescriptor(Diagnostic diagnostic, bool verbos } } - public async ValueTask HandleStaticAssetChangesAsync(IReadOnlyList files, ProjectNodeMap projectMap, CancellationToken cancellationToken) - { - var allFilesHandled = true; + private static readonly string[] s_targets = [TargetNames.GenerateComputedBuildStaticWebAssets, TargetNames.ResolveReferencedProjectsStaticWebAssets]; - var updates = new Dictionary>(); + private static bool HasScopedCssTargets(ProjectInstance projectInstance) + => s_targets.All(t => projectInstance.Targets.ContainsKey(t)); + + public async ValueTask HandleStaticAssetChangesAsync( + IReadOnlyList files, + ProjectNodeMap projectMap, + IReadOnlyDictionary manifests, + CancellationToken cancellationToken) + { + var assets = new Dictionary>(); + var projectInstancesToRegenerate = new HashSet(); foreach (var changedFile in files) { var file = changedFile.Item; + var isScopedCss = StaticWebAsset.IsScopedCssFile(file.FilePath); - if (file.StaticWebAssetPath is null) + if (!isScopedCss && file.StaticWebAssetRelativeUrl is null) { - allFilesHandled = false; continue; } @@ -572,48 +582,145 @@ public async ValueTask HandleStaticAssetChangesAsync(IReadOnlyList? failedApplicationProjectInstances = null; + if (projectInstancesToRegenerate.Count > 0) + { + var buildReporter = new BuildReporter(_context.BuildLogger, _context.Options, _context.EnvironmentOptions); + + // Note: MSBuild only allows one build at a time in a process. + foreach (var projectInstance in projectInstancesToRegenerate) + { + Logger.LogDebug("[{Project}] Regenerating scoped CSS bundle.", projectInstance.GetDisplayName()); + + using var loggers = buildReporter.GetLoggers(projectInstance.FullPath, "ScopedCss"); + + // Deep copy so that we don't pollute the project graph: + if (!projectInstance.DeepCopy().Build(s_targets, loggers)) + { + loggers.ReportOutput(); + + failedApplicationProjectInstances ??= []; + failedApplicationProjectInstances.Add(projectInstance); + } + } } - var tasks = updates.Select(async entry => + var tasks = assets.Select(async entry => { - var (runningProject, assets) = entry; - using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedCancellationToken, cancellationToken); - await runningProject.Clients.ApplyStaticAssetUpdatesAsync(assets, processCommunicationCancellationSource.Token); + var (applicationProjectInstance, instanceAssets) = entry; + + if (failedApplicationProjectInstances?.Contains(applicationProjectInstance) == true) + { + return; + } + + if (!TryGetRunningProject(applicationProjectInstance.FullPath, out var runningProjects)) + { + return; + } + + foreach (var runningProject in runningProjects) + { + using var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(runningProject.ProcessExitedCancellationToken, cancellationToken); + await runningProject.Clients.ApplyStaticAssetUpdatesAsync(instanceAssets.Values, processCommunicationCancellationSource.Token); + } }); await Task.WhenAll(tasks).WaitAsync(cancellationToken); - _logger.Log(MessageDescriptor.HotReloadOfStaticAssetsSucceeded); - - return allFilesHandled; + Logger.Log(MessageDescriptor.StaticAssetsReloaded); } /// diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs index 52df0fa62829..b596468481af 100644 --- a/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs +++ b/src/BuiltInTools/Watch/HotReload/HotReloadDotNetWatcher.cs @@ -3,6 +3,8 @@ using System.Collections.Immutable; using System.Diagnostics; +using System.Text.Encodings.Web; +using Microsoft.Build.Execution; using Microsoft.Build.Graph; using Microsoft.CodeAnalysis; using Microsoft.DotNet.HotReload; @@ -113,8 +115,7 @@ public async Task WatchAsync(CancellationToken shutdownCancellationToken) } var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, _context.Logger); - compilationHandler = new CompilationHandler(_context.Logger, _context.ProcessRunner); - var scopedCssFileHandler = new ScopedCssFileHandler(_context.Logger, _context.BuildLogger, projectMap, _context.BrowserRefreshServerFactory, _context.Options, _context.EnvironmentOptions); + compilationHandler = new CompilationHandler(_context); var projectLauncher = new ProjectLauncher(_context, projectMap, compilationHandler, iteration); evaluationResult.ItemExclusions.Report(_context.Logger); @@ -260,13 +261,9 @@ void FileChangedCallback(ChangedPath change) var stopwatch = Stopwatch.StartNew(); HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.StaticHandler); - await compilationHandler.HandleStaticAssetChangesAsync(changedFiles, projectMap, iterationCancellationToken); + await compilationHandler.HandleStaticAssetChangesAsync(changedFiles, projectMap, evaluationResult.StaticWebAssetsManifests, iterationCancellationToken); HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.StaticHandler); - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.ScopedCssHandler); - await scopedCssFileHandler.HandleFileChangesAsync(changedFiles, iterationCancellationToken); - HotReloadEventSource.Log.HotReloadEnd(HotReloadEventSource.StartType.ScopedCssHandler); - HotReloadEventSource.Log.HotReloadStart(HotReloadEventSource.StartType.CompilationHandler); var (managedCodeUpdates, projectsToRebuild, projectsToRedeploy, projectsToRestart) = await compilationHandler.HandleManagedCodeChangesAsync( @@ -399,7 +396,7 @@ await Task.WhenAll( _context.Logger.Log(MessageDescriptor.HotReloadChangeHandled, stopwatch.ElapsedMilliseconds); - async Task> CaptureChangedFilesSnapshot(ImmutableArray rebuiltProjects) + async Task> CaptureChangedFilesSnapshot(ImmutableArray rebuiltProjects) { var changedPaths = Interlocked.Exchange(ref changedFilesAccumulator, []); if (changedPaths is []) @@ -432,22 +429,14 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra new FileItem() { FilePath = changedPath.Path, ContainingProjectPaths = [] }, changedPath.Kind); }) - .ToImmutableList(); + .ToList(); ReportFileChanges(changedFiles); - // When a new file is added we need to run design-time build to find out - // what kind of the file it is and which project(s) does it belong to (can be linked, web asset, etc.). - // We also need to re-evaluate the project if any project files have been modified. - // We don't need to rebuild and restart the application though. - var fileAdded = changedFiles.Any(f => f.Kind is ChangeKind.Add); - var projectChanged = !fileAdded && changedFiles.Any(f => evaluationResult.BuildFiles.Contains(f.Item.FilePath)); - var evaluationRequired = fileAdded || projectChanged; + AnalyzeFileChanges(changedFiles, evaluationResult, out var evaluationRequired); if (evaluationRequired) { - _context.Logger.Log(fileAdded ? MessageDescriptor.FileAdditionTriggeredReEvaluation : MessageDescriptor.ProjectChangeTriggeredReEvaluation); - // TODO: consider re-evaluating only affected projects instead of the whole graph. evaluationResult = await EvaluateRootProjectAsync(restore: true, iterationCancellationToken); @@ -463,9 +452,14 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra } // Update files in the change set with new evaluation info. - changedFiles = [.. changedFiles - .Select(f => evaluationResult.Files.TryGetValue(f.Item.FilePath, out var evaluatedFile) ? f with { Item = evaluatedFile } : f) - ]; + for (var i = 0; i < changedFiles.Count; i++) + { + var file = changedFiles[i]; + if (evaluationResult.Files.TryGetValue(file.Item.FilePath, out var evaluatedFile)) + { + changedFiles[i] = file with { Item = evaluatedFile }; + } + } _context.Logger.Log(MessageDescriptor.ReEvaluationCompleted); } @@ -478,13 +472,13 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra var rebuiltProjectPaths = rebuiltProjects.ToHashSet(); var newAccumulator = ImmutableList.Empty; - var newChangedFiles = ImmutableList.Empty; + var newChangedFiles = new List(); foreach (var file in changedFiles) { if (file.Item.ContainingProjectPaths.All(containingProjectPath => rebuiltProjectPaths.Contains(containingProjectPath))) { - newChangedFiles = newChangedFiles.Add(file); + newChangedFiles.Add(file); } else { @@ -504,7 +498,7 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra await compilationHandler.Workspace.UpdateFileContentAsync(changedFiles, iterationCancellationToken); } - return changedFiles; + return [.. changedFiles]; } } } @@ -560,6 +554,102 @@ async Task> CaptureChangedFilesSnapshot(ImmutableArra } } + private void AnalyzeFileChanges( + List changedFiles, + EvaluationResult evaluationResult, + out bool evaluationRequired) + { + // If any build file changed (project, props, targets) we need to re-evaluate the projects. + // Currently we re-evaluate the whole project graph even if only a single project file changed. + if (changedFiles.Select(f => f.Item.FilePath).FirstOrDefault(path => evaluationResult.BuildFiles.Contains(path) || MatchesBuildFile(path)) is { } firstBuildFilePath) + { + _context.Logger.Log(MessageDescriptor.ProjectChangeTriggeredReEvaluation, firstBuildFilePath); + evaluationRequired = true; + return; + } + + for (var i = 0; i < changedFiles.Count; i++) + { + var changedFile = changedFiles[i]; + var filePath = changedFile.Item.FilePath; + + if (changedFile.Kind is ChangeKind.Add) + { + if (MatchesStaticWebAssetFilePattern(evaluationResult, filePath, out var staticWebAssetUrl)) + { + changedFiles[i] = changedFile with + { + Item = changedFile.Item with { StaticWebAssetRelativeUrl = staticWebAssetUrl } + }; + } + else + { + // TODO: Get patterns from evaluation that match Compile, AdditionalFile, AnalyzerConfigFile items. + // Avoid re-evaluating on addition of files that don't affect the project. + + // project file or other file: + _context.Logger.Log(MessageDescriptor.FileAdditionTriggeredReEvaluation, filePath); + evaluationRequired = true; + return; + } + } + } + + evaluationRequired = false; + } + + /// + /// True if the file path looks like a file that might be imported by MSBuild. + /// + private static bool MatchesBuildFile(string filePath) + { + var extension = Path.GetExtension(filePath); + return extension.Equals(".props", PathUtilities.OSSpecificPathComparison) + || extension.Equals(".targets", PathUtilities.OSSpecificPathComparison) + || extension.EndsWith("proj", PathUtilities.OSSpecificPathComparison) + || string.Equals(Path.GetFileName(filePath), "global.json", PathUtilities.OSSpecificPathComparison); + } + + /// + /// Determines if the given file path is a static web asset file path based on + /// the discovery patterns. + /// + private static bool MatchesStaticWebAssetFilePattern(EvaluationResult evaluationResult, string filePath, out string? staticWebAssetUrl) + { + staticWebAssetUrl = null; + + if (StaticWebAsset.IsScopedCssFile(filePath)) + { + return true; + } + + foreach (var (_, manifest) in evaluationResult.StaticWebAssetsManifests) + { + foreach (var pattern in manifest.DiscoveryPatterns) + { + var match = pattern.Glob.MatchInfo(filePath); + if (match.IsMatch) + { + var dirUrl = match.WildcardDirectoryPartMatchGroup.Replace(Path.DirectorySeparatorChar, '/'); + + Debug.Assert(!dirUrl.EndsWith('/')); + Debug.Assert(!pattern.BaseUrl.EndsWith('/')); + + var url = UrlEncoder.Default.Encode(dirUrl + "/" + match.FilenamePartMatchGroup); + if (pattern.BaseUrl != "") + { + url = pattern.BaseUrl + "/" + url; + } + + staticWebAssetUrl = url; + return true; + } + } + } + + return false; + } + private void DeployProjectDependencies(ProjectGraph graph, ImmutableArray projectPaths, CancellationToken cancellationToken) { var projectPathSet = projectPaths.ToImmutableHashSet(PathUtilities.OSSpecificPathComparer); diff --git a/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs b/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs index 04b3dbb70d52..5fb26269ae33 100644 --- a/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs +++ b/src/BuiltInTools/Watch/HotReload/HotReloadEventSource.cs @@ -14,7 +14,6 @@ public enum StartType Main, StaticHandler, CompilationHandler, - ScopedCssHandler } internal sealed class Keywords diff --git a/src/BuiltInTools/Watch/HotReload/ScopedCssFileHandler.cs b/src/BuiltInTools/Watch/HotReload/ScopedCssFileHandler.cs deleted file mode 100644 index 09e33759e7a4..000000000000 --- a/src/BuiltInTools/Watch/HotReload/ScopedCssFileHandler.cs +++ /dev/null @@ -1,107 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - - -using Microsoft.Build.Graph; -using Microsoft.Extensions.Logging; - -namespace Microsoft.DotNet.Watch -{ - internal sealed class ScopedCssFileHandler(ILogger logger, ILogger buildLogger, ProjectNodeMap projectMap, BrowserRefreshServerFactory browserConnector, GlobalOptions options, EnvironmentOptions environmentOptions) - { - private const string BuildTargetName = TargetNames.GenerateComputedBuildStaticWebAssets; - - public async ValueTask HandleFileChangesAsync(IReadOnlyList files, CancellationToken cancellationToken) - { - var projectsToRefresh = new HashSet(); - var hasApplicableFiles = false; - - for (int i = 0; i < files.Count; i++) - { - var file = files[i].Item; - - if (!file.FilePath.EndsWith(".razor.css", StringComparison.Ordinal) && - !file.FilePath.EndsWith(".cshtml.css", StringComparison.Ordinal)) - { - continue; - } - - hasApplicableFiles = true; - logger.LogDebug("Handling file change event for scoped css file {FilePath}.", file.FilePath); - foreach (var containingProjectPath in file.ContainingProjectPaths) - { - if (!projectMap.Map.TryGetValue(containingProjectPath, out var projectNodes)) - { - // Shouldn't happen. - logger.LogWarning("Project '{Path}' not found in the project graph.", containingProjectPath); - continue; - } - - // Build and refresh each instance (TFM) of the project. - foreach (var projectNode in projectNodes) - { - // The outer build project instance (that specifies TargetFrameworks) won't have the target. - if (projectNode.ProjectInstance.Targets.ContainsKey(BuildTargetName)) - { - projectsToRefresh.Add(projectNode); - } - } - } - } - - if (!hasApplicableFiles) - { - return; - } - - var buildReporter = new BuildReporter(buildLogger, options, environmentOptions); - - var buildTasks = projectsToRefresh.Select(projectNode => Task.Run(() => - { - using var loggers = buildReporter.GetLoggers(projectNode.ProjectInstance.FullPath, BuildTargetName); - - // Deep copy so that we don't pollute the project graph: - if (!projectNode.ProjectInstance.DeepCopy().Build(BuildTargetName, loggers)) - { - loggers.ReportOutput(); - return null; - } - - return projectNode; - })); - - var buildResults = await Task.WhenAll(buildTasks).WaitAsync(cancellationToken); - - var browserRefreshTasks = buildResults.Where(p => p != null)!.GetAncestorsAndSelf().Select(async projectNode => - { - if (browserConnector.TryGetRefreshServer(projectNode, out var browserRefreshServer)) - { - // We'd like an accurate scoped css path, but this needs a lot of work to wire-up now. - // We'll handle this as part of https://github.com/dotnet/aspnetcore/issues/31217. - // For now, we'll make it look like some css file which would cause JS to update a - // single file if it's from the current project, or all locally hosted css files if it's a file from - // referenced project. - var relativeUrl = Path.GetFileNameWithoutExtension(projectNode.ProjectInstance.FullPath) + ".css"; - await browserRefreshServer.UpdateStaticAssetsAsync([relativeUrl], cancellationToken); - } - }); - - await Task.WhenAll(browserRefreshTasks).WaitAsync(cancellationToken); - - var successfulCount = buildResults.Sum(r => r != null ? 1 : 0); - - if (successfulCount == buildResults.Length) - { - logger.Log(MessageDescriptor.HotReloadOfScopedCssSucceeded); - } - else if (successfulCount > 0) - { - logger.Log(MessageDescriptor.HotReloadOfScopedCssPartiallySucceeded, successfulCount, buildResults.Length); - } - else - { - logger.Log(MessageDescriptor.HotReloadOfScopedCssFailed); - } - } - } -} diff --git a/src/BuiltInTools/Watch/UI/IReporter.cs b/src/BuiltInTools/Watch/UI/IReporter.cs index 82b60996e1ab..079ce3e46101 100644 --- a/src/BuiltInTools/Watch/UI/IReporter.cs +++ b/src/BuiltInTools/Watch/UI/IReporter.cs @@ -196,12 +196,12 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) public static readonly MessageDescriptor ConnectedToRefreshServer = Create(LogEvents.ConnectedToRefreshServer, Emoji.Default); public static readonly MessageDescriptor RestartingApplicationToApplyChanges = Create("Restarting application to apply changes ...", Emoji.Default, LogLevel.Information); public static readonly MessageDescriptor RestartingApplication = Create("Restarting application ...", Emoji.Default, LogLevel.Information); - public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor IgnoringChangeInExcludedFile = Create("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = Create("File addition triggered re-evaluation.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = Create("Ignoring change in hidden directory '{0}': {1} '{2}'", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = Create("Ignoring change in output directory: {0} '{1}'", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor IgnoringChangeInExcludedFile = Create("Ignoring change in excluded file '{0}': {1}. Path matches {2} glob '{3}' set in '{4}'.", Emoji.Watch, LogLevel.Trace); + public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = Create("File addition triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor ProjectChangeTriggeredReEvaluation = Create("Project change triggered re-evaluation: '{0}'.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor ReEvaluationCompleted = Create("Re-evaluation completed.", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor ProjectChangeTriggeredReEvaluation = Create("Project change triggered re-evaluation.", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor NoCSharpChangesToApply = Create("No C# changes to apply.", Emoji.Watch, LogLevel.Information); public static readonly MessageDescriptor Exited = Create("Exited", Emoji.Watch, LogLevel.Information); public static readonly MessageDescriptor ExitedWithUnknownErrorCode = Create("Exited with unknown error code", Emoji.Error, LogLevel.Error); @@ -215,10 +215,7 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) public static readonly MessageDescriptor TerminatingProcess = Create("Terminating process {0} ({1}).", Emoji.Watch, LogLevel.Debug); public static readonly MessageDescriptor FailedToSendSignalToProcess = Create("Failed to send {0} signal to process {1}: {2}", Emoji.Warning, LogLevel.Warning); public static readonly MessageDescriptor ErrorReadingProcessOutput = Create("Error reading {0} of process {1}: {2}", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor HotReloadOfScopedCssSucceeded = Create("Hot reload of scoped css succeeded.", Emoji.HotReload, LogLevel.Information); - public static readonly MessageDescriptor HotReloadOfScopedCssPartiallySucceeded = Create("Hot reload of scoped css partially succeeded: {0} project(s) out of {1} were updated.", Emoji.HotReload, LogLevel.Information); - public static readonly MessageDescriptor HotReloadOfScopedCssFailed = Create("Hot reload of scoped css failed.", Emoji.Error, LogLevel.Error); - public static readonly MessageDescriptor HotReloadOfStaticAssetsSucceeded = Create("Hot reload of static assets succeeded.", Emoji.HotReload, LogLevel.Information); + public static readonly MessageDescriptor StaticAssetsReloaded = Create("Static assets reloaded.", Emoji.HotReload, LogLevel.Information); public static readonly MessageDescriptor SendingStaticAssetUpdateRequest = Create(LogEvents.SendingStaticAssetUpdateRequest, Emoji.Default); public static readonly MessageDescriptor HotReloadCapabilities = Create("Hot reload capabilities: {0}.", Emoji.HotReload, LogLevel.Debug); public static readonly MessageDescriptor HotReloadSuspended = Create("Hot reload suspended. To continue hot reload, press \"Ctrl + R\".", Emoji.HotReload, LogLevel.Information); @@ -232,7 +229,7 @@ public MessageDescriptor WithLevelWhen(LogLevel level, bool condition) public static readonly MessageDescriptor ApplicationKind_WebApplication = Create("Application kind: WebApplication.", Emoji.Default, LogLevel.Debug); public static readonly MessageDescriptor ApplicationKind_Default = Create("Application kind: Default.", Emoji.Default, LogLevel.Debug); public static readonly MessageDescriptor WatchingFilesForChanges = Create("Watching {0} file(s) for changes", Emoji.Watch, LogLevel.Debug); - public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, LogLevel.Debug); + public static readonly MessageDescriptor WatchingFilesForChanges_FilePath = Create("> {0}", Emoji.Watch, LogLevel.Trace); public static readonly MessageDescriptor Building = Create("Building {0} ...", Emoji.Default, LogLevel.Information); public static readonly MessageDescriptor BuildSucceeded = Create("Build succeeded: {0}", Emoji.Default, LogLevel.Information); public static readonly MessageDescriptor BuildFailed = Create("Build failed: {0}", Emoji.Default, LogLevel.Information); diff --git a/src/BuiltInTools/dotnet-watch.slnf b/src/BuiltInTools/dotnet-watch.slnf index 181ae2f51023..5958ed1c0844 100644 --- a/src/BuiltInTools/dotnet-watch.slnf +++ b/src/BuiltInTools/dotnet-watch.slnf @@ -18,21 +18,20 @@ "src\\BuiltInTools\\HotReloadAgent\\Microsoft.DotNet.HotReload.Agent.shproj", "src\\BuiltInTools\\HotReloadClient\\Microsoft.DotNet.HotReload.Client.Package.csproj", "src\\BuiltInTools\\HotReloadClient\\Microsoft.DotNet.HotReload.Client.shproj", - "src\\BuiltInTools\\Web.Middleware\\Microsoft.DotNet.HotReload.Web.Middleware.Package.csproj", - "src\\BuiltInTools\\Web.Middleware\\Microsoft.DotNet.HotReload.Web.Middleware.shproj", "src\\BuiltInTools\\Watch.Aspire\\Microsoft.DotNet.HotReload.Watch.Aspire.csproj", "src\\BuiltInTools\\Watch\\Microsoft.DotNet.HotReload.Watch.csproj", + "src\\BuiltInTools\\Web.Middleware\\Microsoft.DotNet.HotReload.Web.Middleware.Package.csproj", + "src\\BuiltInTools\\Web.Middleware\\Microsoft.DotNet.HotReload.Web.Middleware.shproj", "src\\BuiltInTools\\dotnet-watch\\dotnet-watch.csproj", "src\\Microsoft.DotNet.ProjectTools\\Microsoft.DotNet.ProjectTools.csproj", "test\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj", "test\\Microsoft.DotNet.HotReload.Client.Tests\\Microsoft.DotNet.HotReload.Client.Tests.csproj", + "test\\Microsoft.DotNet.HotReload.Test.Utilities\\Microsoft.DotNet.HotReload.Test.Utilities.csproj", + "test\\Microsoft.DotNet.HotReload.Watch.Aspire.Tests\\Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj", "test\\Microsoft.Extensions.DotNetDeltaApplier.Tests\\Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj", "test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj", "test\\Microsoft.WebTools.AspireService.Tests\\Microsoft.WebTools.AspireService.Tests.csproj", "test\\dotnet-watch-test-browser\\dotnet-watch-test-browser.csproj", - "test\\Microsoft.DotNet.HotReload.Test.Utilities\\Microsoft.DotNet.HotReload.Test.Utilities.csproj", - "test\\Microsoft.DotNet.HotReload.Watch.Aspire.Tests\\Microsoft.DotNet.HotReload.Watch.Aspire.Tests.csproj", - "test\\Microsoft.Extensions.DotNetDeltaApplier.Tests\\Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj", "test\\dotnet-watch.Tests\\dotnet-watch.Tests.csproj" ] } diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs index 384df13ae5cc..476108f319c7 100644 --- a/src/BuiltInTools/dotnet-watch/Program.cs +++ b/src/BuiltInTools/dotnet-watch/Program.cs @@ -46,6 +46,9 @@ public static async Task Main(string[] args) var environmentOptions = EnvironmentOptions.FromEnvironment(processPath); + // msbuild tasks depend on host path variable: + Environment.SetEnvironmentVariable(EnvironmentVariables.Names.DotnetHostPath, environmentOptions.MuxerPath); + var program = TryCreate( args, new PhysicalConsole(environmentOptions.TestFlags), diff --git a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json index 9e28729eb807..811e5676280b 100644 --- a/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json +++ b/src/BuiltInTools/dotnet-watch/Properties/launchSettings.json @@ -2,14 +2,15 @@ "profiles": { "dotnet-watch": { "commandName": "Project", - "commandLineArgs": "--verbose -bl", - "workingDirectory": "C:\\bugs\\9756\\aspire-watch-start-issue\\Aspire.AppHost", + "commandLineArgs": "-bl -f net10.0-windows10.0.19041.0", + "workingDirectory": "C:\\Temp\\MauiBlazor4\\App", "environmentVariables": { "DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)", "DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000", "DCP_IDE_NOTIFICATION_TIMEOUT_SECONDS": "100000", "DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS": "100000", - "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "1" + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "1", + "DOTNET_CLI_CONTEXT_VERBOSE": "true" } } } diff --git a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs index 32f8f2a0d58d..22e2a05ce912 100644 --- a/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/MsBuildFileSetFactory.cs @@ -107,7 +107,7 @@ void AddFile(string filePath, string? staticWebAssetPath) { FilePath = filePath, ContainingProjectPaths = [projectPath], - StaticWebAssetPath = staticWebAssetPath, + StaticWebAssetRelativeUrl = staticWebAssetPath, }); } else if (!existingFile.ContainingProjectPaths.Contains(projectPath)) diff --git a/src/BuiltInTools/Watch/HotReload/StaticFileHandler.cs b/src/BuiltInTools/dotnet-watch/Watch/StaticFileHandler.cs similarity index 95% rename from src/BuiltInTools/Watch/HotReload/StaticFileHandler.cs rename to src/BuiltInTools/dotnet-watch/Watch/StaticFileHandler.cs index d20603095ab3..538493e1101f 100644 --- a/src/BuiltInTools/Watch/HotReload/StaticFileHandler.cs +++ b/src/BuiltInTools/dotnet-watch/Watch/StaticFileHandler.cs @@ -19,7 +19,7 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList f { var file = files[i].Item; - if (file.StaticWebAssetPath is null) + if (file.StaticWebAssetRelativeUrl is null) { allFilesHandled = false; continue; @@ -46,7 +46,7 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList f refreshRequests.Add(refreshServer, filesPerServer = []); } - filesPerServer.Add(file.StaticWebAssetPath); + filesPerServer.Add(file.StaticWebAssetRelativeUrl); } else if (projectsWithoutRefreshServer.Add(projectNode)) { @@ -65,7 +65,7 @@ public async ValueTask HandleFileChangesAsync(IReadOnlyList f await Task.WhenAll(tasks).WaitAsync(cancellationToken); - logger.Log(MessageDescriptor.HotReloadOfStaticAssetsSucceeded); + logger.Log(MessageDescriptor.StaticAssetsReloaded); return allFilesHandled; } diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj index 19c3be5116f6..1253990897ce 100644 --- a/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/Microsoft.DotNet.HotReload.Client.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetsManifestTests.cs b/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetsManifestTests.cs new file mode 100644 index 000000000000..f2478425388d --- /dev/null +++ b/test/Microsoft.DotNet.HotReload.Client.Tests/StaticWebAssetsManifestTests.cs @@ -0,0 +1,383 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.Watch.UnitTests; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.DotNet.HotReload.UnitTests; + +public class StaticWebAssetsManifestTests(ITestOutputHelper testOutput) +{ + private static MemoryStream CreateStream(string content) + => new(Encoding.UTF8.GetBytes(content)); + + [Fact] + public void TryParse_Empty() + { + using var stream = CreateStream("null"); + var logger = new TestLogger(testOutput); + Assert.Null(StaticWebAssetsManifest.TryParse(stream, "file.json", logger)); + Assert.True(logger.HasError); + } + + [Fact] + public void TryParse_MissingContentRoots() + { + using var stream = CreateStream(""" + { + "Root": { + "Children": { + "site.css": { + "Asset": { + "ContentRootIndex": 0, + "SubPath": "css/site.css" + } + } + } + } + } + """); + + var logger = new TestLogger(testOutput); + Assert.Null(StaticWebAssetsManifest.TryParse(stream, "file.json", logger)); + Assert.True(logger.HasError); + } + + [Fact] + public void TryParse_InvalidRootIndex() + { + var root = Path.GetTempPath(); + var rootEscaped = root.Replace("\\", "\\\\"); + + using var stream = CreateStream($$""" + { + "ContentRoots": [ + "{{rootEscaped}}" + ], + "Root": { + "Children": { + "site.css": { + "Asset": { + "ContentRootIndex": 1, + "SubPath": "css/site.css" + } + } + } + } + } + """); + + var logger = new TestLogger(testOutput); + var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger); + + AssertEx.SequenceEqual( + [ + "[Warning] Failed to parse 'file.json': Invalid value of ContentRootIndex: 1", + ], logger.GetAndClearMessages()); + + Assert.NotNull(manifest); + Assert.Empty(manifest.UrlToPathMap); + Assert.Empty(manifest.DiscoveryPatterns); + } + + [Fact] + public void TryParse_InvalidCharactersInSubPath() + { + var root = Path.GetTempPath(); + var rootEscaped = root.Replace("\\", "\\\\"); + + using var stream = CreateStream($$""" + { + "ContentRoots": [ + "{{rootEscaped}}" + ], + "Root": { + "Children": { + "site.css": { + "Asset": { + "ContentRootIndex": 0, + "SubPath": "<>.css" + } + } + } + } + } + """); + + var logger = new TestLogger(testOutput); + var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger); + Assert.Empty(logger.GetAndClearMessages()); + Assert.NotNull(manifest); + + AssertEx.SequenceEqual( + [ + new("site.css", Path.Join(root, "<>.css")), + ], manifest.UrlToPathMap.OrderBy(e => e.Key)); + } + + [Fact] + public void TryParse_NoChildren() + { + var root = Path.GetTempPath(); + var rootEscaped = root.Replace("\\", "\\\\"); + + using var stream = CreateStream($$""" + { + "ContentRoots": [ + "{{rootEscaped}}Classlib2\\bundles\\" + ], + "Root": { + "Children": { + } + } + } + """); + + var logger = new TestLogger(testOutput); + var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger); + Assert.NotNull(manifest); + Assert.False(logger.HasWarning); + Assert.Empty(manifest.UrlToPathMap); + Assert.Empty(manifest.DiscoveryPatterns); + } + + [Fact] + public void TryParse_TopAsset() + { + var root = Path.GetTempPath(); + var rootEscaped = root.Replace("\\", "\\\\"); + + using var stream = CreateStream($$""" + { + "ContentRoots": [ + "{{rootEscaped}}Classlib2\\bundles\\" + ], + "Root": { + "Children": null, + "Asset": { + "ContentRootIndex": 0, + "SubPath": "css/site.css" + }, + "Patterns": null + } + } + """); + + var logger = new TestLogger(testOutput); + var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger); + AssertEx.SequenceEqual( + [ + "[Warning] Failed to parse 'file.json': Asset has no URL", + ], logger.GetAndClearMessages()); + + Assert.NotNull(manifest); + Assert.Empty(manifest.UrlToPathMap); + Assert.Empty(manifest.DiscoveryPatterns); + } + + [Fact] + public void TryParse_RootIsNotFullPath() + { + using var stream = CreateStream(""" + { + "ContentRoots": [ + "a/b" + ], + "Root": { + "Children": null, + "Asset": { + "ContentRootIndex": 0, + "SubPath": "css/site.css" + }, + "Patterns": null + } + } + """); + + var logger = new TestLogger(testOutput); + var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger); + Assert.True(logger.HasWarning); + Assert.NotNull(manifest); + Assert.Empty(manifest.UrlToPathMap); + Assert.Empty(manifest.DiscoveryPatterns); + } + + [Fact] + public void TryParse_ValidFile() + { + var root = Path.GetTempPath(); + var rootEscaped = root.Replace("\\", "\\\\"); + + using var stream = CreateStream($$""" + { + "ContentRoots": [ + "{{rootEscaped}}Classlib2\\bundles\\", + "{{rootEscaped}}Classlib2\\scopedcss\\", + "{{rootEscaped}}Classlib2\\SomePath\\", + "{{rootEscaped}}Classlib\\wwwroot\\", + "{{rootEscaped}}Classlib2\\wwwroot\\", + "{{rootEscaped}}Classlib3\\somePath\\" + ], + "Root": { + "Children": { + "css": { + "Children": { + "site.css": { + "Children": null, + "Asset": { + "ContentRootIndex": 0, + "SubPath": "css/site.css" + }, + "Patterns": null + } + }, + "Asset": null, + "Patterns": null + }, + "_content": { + "Children": { + "Classlib": { + "Children": { + "css": { + "Children": { + "site.css": { + "Children": null, + "Asset": { + "ContentRootIndex": 3, + "SubPath": "css/site.css" + }, + "Patterns": null + } + }, + "Asset": null, + "Patterns": null + } + }, + "Asset": null, + "Patterns": null + }, + "Classlib2": { + "Children": { + "Classlib2.bundle.fingerprint.scp.css": { + "Children": null, + "Asset": { + "ContentRootIndex": 2, + "SubPath": "Classlib2.bundle.scp.css" + }, + "Patterns": null + }, + "background.png": { + "Children": null, + "Asset": { + "ContentRootIndex": 4, + "SubPath": "background.png" + }, + "Patterns": null + } + }, + "Asset": null, + "Patterns": null + }, + "Classlib3": { + "Children": { + "background.png": { + "Children": null, + "Asset": { + "ContentRootIndex": 5, + "SubPath": "background.png" + }, + "Patterns": null + } + }, + "Asset": null, + "Patterns": null + } + }, + "Asset": null, + "Patterns": null + }, + "Classlib2.styles.css": { + "Children": null, + "Asset": { + "ContentRootIndex": 0, + "SubPath": "Classlib2.styles.css" + }, + "Patterns": null + }, + "Classlib2.scopedstyles.css": { + "Children": null, + "Asset": { + "ContentRootIndex": 1, + "SubPath": "Classlib2.scopedstyles.css" + }, + "Patterns": null + } + } + } + } + """); + + var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", NullLogger.Instance); + + Assert.NotNull(manifest); + AssertEx.SequenceEqual( + [ + new("_content/Classlib/css/site.css", Path.Combine(root, "Classlib", "wwwroot", "css", "site.css")), + new("_content/Classlib2/background.png", Path.Combine(root, "Classlib2", "wwwroot", "background.png")), + new("_content/Classlib2/Classlib2.bundle.fingerprint.scp.css", Path.Combine(root, "Classlib2", "SomePath", "Classlib2.bundle.scp.css")), + new("_content/Classlib3/background.png", Path.Combine(root, "Classlib3", "somePath", "background.png")), + new("Classlib2.scopedstyles.css", Path.Combine(root, "Classlib2", "scopedcss", "Classlib2.scopedstyles.css")), + new("Classlib2.styles.css", Path.Combine(root, "Classlib2", "bundles", "Classlib2.styles.css")), + new("css/site.css", Path.Combine(root, "Classlib2", "bundles", "css", "site.css")), + ], manifest.UrlToPathMap.OrderBy(e => e.Key)); + + Assert.Empty(manifest.DiscoveryPatterns); + } + + [Fact] + public void TryParse_Patterns() + { + var root = Path.GetTempPath(); + var rootEscaped = root.Replace("\\", "\\\\"); + + using var stream = CreateStream($$""" + { + "ContentRoots": [ + "{{rootEscaped}}" + ], + "Root": { + "Children": { + "site.css" : { + "Asset": { + "ContentRootIndex": 0, + "SubPath": "css/site.css" + } + } + }, + "Patterns": [ + { + "ContentRootIndex": 0, + "Pattern": "**", + "Depth": 0 + } + ] + } + } + """); + + var logger = new TestLogger(testOutput); + var manifest = StaticWebAssetsManifest.TryParse(stream, "file.json", logger); + Assert.False(logger.HasWarning); + Assert.NotNull(manifest); + + AssertEx.SequenceEqual( + [ + new("site.css", Path.Combine(root, "css", "site.css")), + ], manifest.UrlToPathMap.OrderBy(e => e.Key)); + + AssertEx.SequenceEqual( + [ + $"{root};**;" + ], manifest.DiscoveryPatterns.Select(p => $"{p.Directory};{p.Pattern};{p.BaseUrl}")); + } +} diff --git a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs index 562d9e67bd0a..377919334a22 100644 --- a/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs +++ b/test/Microsoft.DotNet.HotReload.Test.Utilities/TestLogger.cs @@ -8,17 +8,23 @@ namespace Microsoft.DotNet.Watch.UnitTests; internal class TestLogger(ITestOutputHelper? output = null) : ILogger { - public readonly Lock Guard = new(); + public readonly object Guard = new(); private readonly List _messages = []; public Func IsEnabledImpl = _ => true; + public bool HasError { get; private set; } + public bool HasWarning { get; private set; } + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { var message = $"[{logLevel}] {formatter(state, exception)}"; lock (Guard) { + HasError |= logLevel is LogLevel.Error or LogLevel.Critical; + HasWarning |= logLevel is LogLevel.Warning; + _messages.Add(message); output?.WriteLine(message); } diff --git a/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj b/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj index ba7abcd3a1cb..bd9a74bd0769 100644 --- a/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj +++ b/test/Microsoft.TemplateEngine.Cli.UnitTests/Microsoft.TemplateEngine.Cli.UnitTests.csproj @@ -5,7 +5,6 @@ enable MicrosoftAspNetCore true - true true diff --git a/test/TestAssets/TestProjects/WatchMauiBlazor/Components/Pages/Counter.razor.css b/test/TestAssets/TestProjects/WatchMauiBlazor/Components/Pages/Counter.razor.css new file mode 100644 index 000000000000..d984992a1f35 --- /dev/null +++ b/test/TestAssets/TestProjects/WatchMauiBlazor/Components/Pages/Counter.razor.css @@ -0,0 +1,5 @@ +.btn-primary { + color: #fff; + background-color: green; + border-color: #1861ac; +} diff --git a/test/dotnet-new.IntegrationTests/dotnet-new.IntegrationTests.csproj b/test/dotnet-new.IntegrationTests/dotnet-new.IntegrationTests.csproj index a5a852ebf2e3..5b912e2f4f11 100644 --- a/test/dotnet-new.IntegrationTests/dotnet-new.IntegrationTests.csproj +++ b/test/dotnet-new.IntegrationTests/dotnet-new.IntegrationTests.csproj @@ -6,7 +6,6 @@ MicrosoftAspNetCore Exe true - true true diff --git a/test/dotnet-watch.Tests/Build/EvaluationTests.cs b/test/dotnet-watch.Tests/Build/EvaluationTests.cs index 4c1bcbdbb30a..dba53d49c746 100644 --- a/test/dotnet-watch.Tests/Build/EvaluationTests.cs +++ b/test/dotnet-watch.Tests/Build/EvaluationTests.cs @@ -576,7 +576,7 @@ string GetRelativePath(string fullPath) => Path.GetRelativePath(testDir, fullPath).Replace('\\', '/'); IEnumerable<(string relativePath, string? staticAssetUrl)> Inspect(IReadOnlyDictionary files) - => files.Select(f => (relativePath: GetRelativePath(f.Key), staticAssetUrl: f.Value.StaticWebAssetPath)).OrderBy(f => f.relativePath); + => files.Select(f => (relativePath: GetRelativePath(f.Key), staticAssetUrl: f.Value.StaticWebAssetRelativeUrl)).OrderBy(f => f.relativePath); IEnumerable<(string relativePath, string? staticAssetUrl)> ParseOutput(IEnumerable output) { diff --git a/test/dotnet-watch.Tests/Build/FileSetSerializerTests.cs b/test/dotnet-watch.Tests/Build/FileSetSerializerTests.cs index 77bd1b8abe68..218fac06daec 100644 --- a/test/dotnet-watch.Tests/Build/FileSetSerializerTests.cs +++ b/test/dotnet-watch.Tests/Build/FileSetSerializerTests.cs @@ -144,7 +144,7 @@ public async Task Task() { { "FullPath", "file.css" }, { "ProjectFullPath", "ProjectB.csproj" }, - { "StaticWebAssetPath", "/wwwroot/a/b/file.css" } + { "StaticWebAssetRelativeUrl", "/wwwroot/a/b/file.css" } }) ] }; diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index 095822120c0d..b8f159807374 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -998,7 +998,7 @@ public async Task Razor_Component_ScopedCssAndStaticAssets() await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled); App.AssertOutputContains(MessageDescriptor.SendingStaticAssetUpdateRequest.GetMessage("RazorApp.css")); - App.AssertOutputContains(MessageDescriptor.HotReloadOfScopedCssSucceeded); + App.AssertOutputContains(MessageDescriptor.StaticAssetsReloaded); App.AssertOutputContains(MessageDescriptor.NoCSharpChangesToApply); App.Process.ClearOutput(); @@ -1009,7 +1009,7 @@ public async Task Razor_Component_ScopedCssAndStaticAssets() // "wwwroot" directory is required for MAUI. Web sites work with or without it. App.AssertOutputContains(MessageDescriptor.SendingStaticAssetUpdateRequest.GetMessage("wwwroot/app.css")); - App.AssertOutputContains(MessageDescriptor.HotReloadOfStaticAssetsSucceeded); + App.AssertOutputContains(MessageDescriptor.StaticAssetsReloaded); App.AssertOutputContains(MessageDescriptor.NoCSharpChangesToApply); App.Process.ClearOutput(); } @@ -1018,7 +1018,7 @@ public async Task Razor_Component_ScopedCssAndStaticAssets() /// Currently only works on Windows. /// Add TestPlatforms.OSX once https://github.com/dotnet/sdk/issues/45521 is fixed. /// - [PlatformSpecificFact(TestPlatforms.Windows, Skip = "https://github.com/dotnet/sdk/issues/40006")] + [PlatformSpecificFact(TestPlatforms.Windows)] public async Task MauiBlazor() { var testAsset = TestAssets.CopyTestAsset("WatchMauiBlazor") @@ -1053,6 +1053,16 @@ public async Task MauiBlazor() var cssPath = Path.Combine(testAsset.Path, "wwwroot", "css", "app.css"); UpdateSourceFile(cssPath, content => content.Replace("background-color: white;", "background-color: red;")); + await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled); + App.AssertOutputContains("Updates applied: 1 out of 1."); + App.AssertOutputContains("Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager.UpdateContent"); + App.AssertOutputContains("No C# changes to apply."); + App.Process.ClearOutput(); + + // update scoped css: + var scopedCssPath = Path.Combine(testAsset.Path, "Components", "Pages", "Counter.razor.css"); + UpdateSourceFile(scopedCssPath, content => content.Replace("background-color: green", "background-color: red")); + await App.WaitForOutputLineContaining(MessageDescriptor.HotReloadChangeHandled); App.AssertOutputContains("Updates applied: 1 out of 1."); App.AssertOutputContains("Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager.UpdateContent"); diff --git a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs index d32c0d5edb71..46e69aade1ab 100644 --- a/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs +++ b/test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Microsoft.DotNet.Watch.UnitTests; @@ -18,17 +19,28 @@ public async Task ReferenceOutputAssembly_False() var hostProject = Path.Combine(hostDir, "Host.csproj"); var options = TestOptions.GetProjectOptions(["--project", hostProject]); - var environmentOptions = TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet"); - var processRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero); - - var reporter = new TestReporter(Logger); - var loggerFactory = new LoggerFactory(reporter, LogLevel.Debug); - var logger = loggerFactory.CreateLogger("Test"); var factory = new ProjectGraphFactory(globalOptions: []); - var projectGraph = factory.TryLoadProjectGraph(options.ProjectPath, logger, projectGraphRequired: false, CancellationToken.None); - var handler = new CompilationHandler(logger, processRunner); + var projectGraph = factory.TryLoadProjectGraph(options.ProjectPath, NullLogger.Instance, projectGraphRequired: false, CancellationToken.None); + + var processOutputReporter = new TestProcessOutputReporter(); + + var context = new DotNetWatchContext() + { + ProcessOutputReporter = processOutputReporter, + Logger = NullLogger.Instance, + BuildLogger = NullLogger.Instance, + LoggerFactory = NullLoggerFactory.Instance, + ProcessRunner = new ProcessRunner(processCleanupTimeout: TimeSpan.Zero), + Options = new(), + RootProjectOptions = TestOptions.ProjectOptions, + EnvironmentOptions = environmentOptions, + BrowserLauncher = new BrowserLauncher(NullLogger.Instance, processOutputReporter, environmentOptions), + BrowserRefreshServerFactory = new BrowserRefreshServerFactory() + }; + + var handler = new CompilationHandler(context); await handler.Workspace.UpdateProjectConeAsync(hostProject, CancellationToken.None);