diff --git a/sdk.sln b/sdk.sln
index f1ef890d39f9..75023cebd298 100644
--- a/sdk.sln
+++ b/sdk.sln
@@ -272,6 +272,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnet-watch.Tests", "src\T
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Watch.BrowserRefresh.Tests", "src\Tests\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj", "{81ADA3FA-AC26-4149-8CFC-EC7808ECB820}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetWatchTasks", "src\BuiltInTools\DotNetWatchTasks\DotNetWatchTasks.csproj", "{A41DF752-6F21-4036-AD02-DD37B11A2723}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -498,6 +500,10 @@ Global
{81ADA3FA-AC26-4149-8CFC-EC7808ECB820}.Debug|Any CPU.Build.0 = Debug|Any CPU
{81ADA3FA-AC26-4149-8CFC-EC7808ECB820}.Release|Any CPU.ActiveCfg = Release|Any CPU
{81ADA3FA-AC26-4149-8CFC-EC7808ECB820}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A41DF752-6F21-4036-AD02-DD37B11A2723}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A41DF752-6F21-4036-AD02-DD37B11A2723}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A41DF752-6F21-4036-AD02-DD37B11A2723}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A41DF752-6F21-4036-AD02-DD37B11A2723}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -590,6 +596,7 @@ Global
{A82EF2B9-24BC-4569-8FE5-9EF51017F4CB} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91}
{CCE1A328-9CFE-44D3-B68F-FE84A039ACEA} = {580D1AE7-AA8F-4912-8B76-105594E00B3B}
{81ADA3FA-AC26-4149-8CFC-EC7808ECB820} = {580D1AE7-AA8F-4912-8B76-105594E00B3B}
+ {A41DF752-6F21-4036-AD02-DD37B11A2723} = {71A9F549-0EB6-41F9-BC16-4A6C5007FC91}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FB8F26CE-4DE6-433F-B32A-79183020BBD6}
diff --git a/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js b/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js
index d957e285bd20..b03a14601a67 100644
--- a/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js
+++ b/src/BuiltInTools/BrowserRefresh/WebSocketScriptInjection.js
@@ -7,17 +7,84 @@ setTimeout(function () {
console.debug(ex);
return;
}
+
+ let waiting = false;
+
connection.onmessage = function (message) {
if (message.data === 'Reload') {
console.debug('Server is ready. Reloading...');
location.reload();
} else if (message.data === 'Wait') {
+ if (waiting) {
+ return;
+ }
+ waiting = true;
console.debug('File changes detected. Waiting for application to rebuild.');
- const t = document.title; const r = ['☱', '☲', '☴']; let i = 0;
- setInterval(function () { document.title = r[i++ % r.length] + ' ' + t; }, 240);
+ const glyphs = ['☱', '☲', '☴'];
+ const title = document.title;
+ let i = 0;
+ setInterval(function () { document.title = glyphs[i++ % glyphs.length] + ' ' + title; }, 240);
+ } else {
+ const parsed = JSON.parse(message.data);
+ if (parsed.type == 'UpdateStaticFile') {
+ const path = parsed.path;
+ if (path && path.endsWith('.css')) {
+ updateCssByPath(path);
+ } else {
+ console.debug(`File change detected to css file ${path}. Reloading page...`);
+ location.reload();
+ return;
+ }
+ }
}
}
+
connection.onerror = function (event) { console.debug('dotnet-watch reload socket error.', event) }
connection.onclose = function () { console.debug('dotnet-watch reload socket closed.') }
connection.onopen = function () { console.debug('dotnet-watch reload socket connected.') }
+
+ function updateCssByPath(path) {
+ const styleElement = document.querySelector(`link[href^="${path}"]`) ||
+ document.querySelector(`link[href^="${document.baseURI}${path}"]`);
+
+ if (!styleElement || !styleElement.parentNode) {
+ console.debug('Unable to find a stylesheet to update. Updating all css.');
+ updateAllLocalCss();
+ }
+
+ updateCssElement(styleElement);
+ }
+
+ function updateAllLocalCss() {
+ [...document.querySelectorAll('link')]
+ .filter(l => l.baseURI === document.baseURI)
+ .forEach(e => updateCssElement(e));
+ }
+
+ function updateCssElement(styleElement) {
+ if (styleElement.loading) {
+ // A file change notification may be triggered for the same file before the browser
+ // finishes processing a previous update. In this case, it's easiest to ignore later updates
+ return;
+ }
+
+ const newElement = styleElement.cloneNode();
+ const href = styleElement.href;
+ newElement.href = href.split('?', 1)[0] + `?nonce=${Date.now()}`;
+
+ styleElement.loading = true;
+ newElement.loading = true;
+ newElement.addEventListener('load', function () {
+ newElement.loading = false;
+ styleElement.remove();
+ });
+
+ styleElement.parentNode.insertBefore(newElement, styleElement.nextSibling);
+ }
+
+ function updateScopedCss() {
+ [...document.querySelectorAll('link')]
+ .filter(l => l.baseURI === document.baseURI && l.href && l.href.indexOf('.styles.css') !== -1)
+ .forEach(e => updateCssElement(e));
+ }
}, 500);
diff --git a/src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj b/src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj
new file mode 100644
index 000000000000..a60c731caaf9
--- /dev/null
+++ b/src/BuiltInTools/DotNetWatchTasks/DotNetWatchTasks.csproj
@@ -0,0 +1,16 @@
+
+
+
+ netstandard2.0
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/BuiltInTools/DotNetWatchTasks/FileSetSerializer.cs b/src/BuiltInTools/DotNetWatchTasks/FileSetSerializer.cs
new file mode 100644
index 000000000000..d8669fa9ee3d
--- /dev/null
+++ b/src/BuiltInTools/DotNetWatchTasks/FileSetSerializer.cs
@@ -0,0 +1,71 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.Serialization.Json;
+using System.Text;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+using Microsoft.DotNet.Watcher.Internal;
+
+namespace DotNetWatchTasks
+{
+ public class FileSetSerializer : Task
+ {
+ public ITaskItem[] WatchFiles { get; set; }
+
+ public bool IsNetCoreApp31OrNewer { get; set; }
+
+ public ITaskItem OutputPath { get; set; }
+
+ public string[] PackageIds { get; set; }
+
+ public override bool Execute()
+ {
+ var projectItems = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var fileSetResult = new MSBuildFileSetResult
+ {
+ IsNetCoreApp31OrNewer = IsNetCoreApp31OrNewer,
+ Projects = projectItems
+ };
+
+ foreach (var item in WatchFiles)
+ {
+ var fullPath = item.GetMetadata("FullPath");
+ var staticWebAssetPath = item.GetMetadata("StaticWebAssetPath");
+ var projectFullPath = item.GetMetadata("ProjectFullPath");
+
+ if (!projectItems.TryGetValue(projectFullPath, out var project))
+ {
+ projectItems[projectFullPath] = project = new ProjectItems();
+ }
+
+ if (string.IsNullOrEmpty(staticWebAssetPath))
+ {
+ project.Files.Add(fullPath);
+ }
+ else
+ {
+ project.StaticFiles.Add(new StaticFileItem
+ {
+ FilePath = fullPath,
+ StaticWebAssetPath = staticWebAssetPath,
+ });
+ }
+ }
+
+ var serializer = new DataContractJsonSerializer(fileSetResult.GetType(), new DataContractJsonSerializerSettings
+ {
+ UseSimpleDictionaryFormat = true,
+ });
+
+ using var fileStream = File.Create(OutputPath.ItemSpec);
+ using var writer = JsonReaderWriterFactory.CreateJsonWriter(fileStream, Encoding.UTF8, ownsStream: false, indent: true);
+ serializer.WriteObject(writer, fileSetResult);
+
+ return !Log.HasLoggedErrors;
+ }
+ }
+}
diff --git a/src/BuiltInTools/dotnet-watch/BrowserRefreshServer.cs b/src/BuiltInTools/dotnet-watch/BrowserRefreshServer.cs
index 7ff3952d28b2..2c7bc52bd73d 100644
--- a/src/BuiltInTools/dotnet-watch/BrowserRefreshServer.cs
+++ b/src/BuiltInTools/dotnet-watch/BrowserRefreshServer.cs
@@ -4,6 +4,7 @@
using System;
using System.Linq;
using System.Net.WebSockets;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
@@ -20,6 +21,8 @@ namespace Microsoft.DotNet.Watcher.Tools
{
public class BrowserRefreshServer : IAsyncDisposable
{
+ private readonly byte[] ReloadMessage = Encoding.UTF8.GetBytes("Reload");
+ private readonly byte[] WaitMessage = Encoding.UTF8.GetBytes("Wait");
private readonly IReporter _reporter;
private readonly TaskCompletionSource _taskCompletionSource;
private IHost _refreshServer;
@@ -73,7 +76,7 @@ private async Task WebSocketRequest(HttpContext context)
await _taskCompletionSource.Task;
}
- public async Task SendMessage(byte[] messageBytes, CancellationToken cancellationToken = default)
+ public async ValueTask SendMessage(ReadOnlyMemory messageBytes, CancellationToken cancellationToken = default)
{
if (_webSocket == null || _webSocket.CloseStatus.HasValue)
{
@@ -105,5 +108,9 @@ public async ValueTask DisposeAsync()
_taskCompletionSource.TrySetResult();
}
+
+ public ValueTask ReloadAsync(CancellationToken cancellationToken) => SendMessage(ReloadMessage, cancellationToken);
+
+ public ValueTask SendWaitMessageAsync(CancellationToken cancellationToken) => SendMessage(WaitMessage, cancellationToken);
}
}
diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatch.targets b/src/BuiltInTools/dotnet-watch/DotNetWatch.targets
index 26316b06e190..cd496d6968ba 100644
--- a/src/BuiltInTools/dotnet-watch/DotNetWatch.targets
+++ b/src/BuiltInTools/dotnet-watch/DotNetWatch.targets
@@ -7,6 +7,8 @@ Main target called by dotnet-watch. It gathers MSBuild items and writes
them to a file.
=========================================================================
-->
+
+
@@ -17,14 +19,10 @@ them to a file.
<_IsMicrosoftNETCoreApp31OrNewer Condition="'$(_IsMicrosoftNETCoreApp31OrNewer)' == ''">false
-
- <_WatchListLine Include="$(_IsMicrosoftNETCoreApp31OrNewer)" />
- <_WatchListLine Include="%(Watch.FullPath)" />
-
-
-
+
+
+
<_WatchProjects Include="%(ProjectReference.Identity)" Condition="'%(ProjectReference.Watch)' != 'false'" />
@@ -77,6 +90,7 @@ Returns: @(Watch)
BuildInParallel="true">
+
diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatchContext.cs b/src/BuiltInTools/dotnet-watch/DotNetWatchContext.cs
index 16b5e453faf4..2fe09ada7d22 100644
--- a/src/BuiltInTools/dotnet-watch/DotNetWatchContext.cs
+++ b/src/BuiltInTools/dotnet-watch/DotNetWatchContext.cs
@@ -11,14 +11,16 @@ public class DotNetWatchContext
public ProcessSpec ProcessSpec { get; set; }
- public IFileSet FileSet { get; set; }
+ public FileSet FileSet { get; set; }
public int Iteration { get; set; }
- public string ChangedFile { get; set; }
+ public FileItem? ChangedFile { get; set; }
public bool RequiresMSBuildRevaluation { get; set; }
public bool SuppressMSBuildIncrementalism { get; set; }
+
+ public BrowserRefreshServer BrowserRefreshServer { get; set; }
}
}
diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatchOptions.cs b/src/BuiltInTools/dotnet-watch/DotNetWatchOptions.cs
new file mode 100644
index 000000000000..043ab69da518
--- /dev/null
+++ b/src/BuiltInTools/dotnet-watch/DotNetWatchOptions.cs
@@ -0,0 +1,30 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.DotNet.Watcher
+{
+ public record DotNetWatchOptions(
+ bool SuppressHandlingStaticContentFiles,
+ bool SuppressMSBuildIncrementalism,
+ bool SuppressLaunchBrowser,
+ bool SuppressBrowserRefresh,
+ bool RunningAsTest)
+ {
+ public static DotNetWatchOptions Default { get; } = new DotNetWatchOptions
+ (
+ SuppressHandlingStaticContentFiles: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_STATIC_FILE_HANDLING"),
+ SuppressMSBuildIncrementalism: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM"),
+ SuppressLaunchBrowser: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"),
+ SuppressBrowserRefresh: IsEnvironmentSet("DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH"),
+ RunningAsTest: IsEnvironmentSet("__DOTNET_WATCH_RUNNING_AS_TEST")
+ );
+
+ private static bool IsEnvironmentSet(string key)
+ {
+ var envValue = Environment.GetEnvironmentVariable(key);
+ return envValue == "1" || envValue == "true";
+ }
+ }
+}
diff --git a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs
index f8c00d71f659..d920d3da4ad6 100644
--- a/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs
+++ b/src/BuiltInTools/dotnet-watch/DotNetWatcher.cs
@@ -2,8 +2,8 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
+using System.Diagnostics;
using System.Globalization;
-using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -17,20 +17,24 @@ public class DotNetWatcher : IAsyncDisposable
{
private readonly IReporter _reporter;
private readonly ProcessRunner _processRunner;
+ private readonly DotNetWatchOptions _dotnetWatchOptions;
+ private readonly FileChangeHandler _fileChangeHandler;
private readonly IWatchFilter[] _filters;
- public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory)
+ public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory, DotNetWatchOptions dotNetWatchOptions)
{
Ensure.NotNull(reporter, nameof(reporter));
_reporter = reporter;
_processRunner = new ProcessRunner(reporter);
+ _dotnetWatchOptions = dotNetWatchOptions;
+ _fileChangeHandler = new FileChangeHandler(reporter);
_filters = new IWatchFilter[]
{
new MSBuildEvaluationFilter(fileSetFactory),
new NoRestoreFilter(),
- new LaunchBrowserFilter(),
+ new LaunchBrowserFilter(dotNetWatchOptions),
};
}
@@ -43,13 +47,12 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
cancelledTaskSource);
var initialArguments = processSpec.Arguments.ToArray();
- var suppressMSBuildIncrementalism = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM");
var context = new DotNetWatchContext
{
Iteration = -1,
ProcessSpec = processSpec,
Reporter = _reporter,
- SuppressMSBuildIncrementalism = suppressMSBuildIncrementalism == "1" || suppressMSBuildIncrementalism == "true",
+ SuppressMSBuildIncrementalism = _dotnetWatchOptions.SuppressMSBuildIncrementalism,
};
if (context.SuppressMSBuildIncrementalism)
@@ -92,15 +95,31 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
currentRunCancellationSource.Token))
using (var fileSetWatcher = new FileSetWatcher(fileSet, _reporter))
{
- var fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token);
var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token);
-
var args = string.Join(" ", processSpec.Arguments);
_reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: {args}");
_reporter.Output("Started");
- var finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task);
+ Task fileSetTask;
+ Task finishedTask;
+
+ while (true)
+ {
+ fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token);
+ finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task);
+
+ if (finishedTask == fileSetTask
+ && fileSetTask.Result is FileItem fileItem &&
+ await _fileChangeHandler.TryHandleFileAction(context, fileItem, combinedCancellationSource.Token))
+ {
+ // We're able to handle the file change event without doing a full-rebuild.
+ }
+ else
+ {
+ break;
+ }
+ }
// Regardless of the which task finished first, make sure everything is cancelled
// and wait for dotnet to exit. We don't want orphan processes
@@ -124,7 +143,6 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
return;
}
- context.ChangedFile = fileSetTask.Result;
if (finishedTask == processTask)
{
// Process exited. Redo evaludation
@@ -132,10 +150,12 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
// Now wait for a file to change before restarting process
context.ChangedFile = await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet..."));
}
-
- if (!string.IsNullOrEmpty(fileSetTask.Result))
+ else
{
- _reporter.Output($"File changed: {fileSetTask.Result}");
+ Debug.Assert(finishedTask == fileSetTask);
+ var changedFile = fileSetTask.Result;
+ context.ChangedFile = changedFile;
+ _reporter.Output($"File changed: {changedFile.Value.FilePath}");
}
}
}
diff --git a/src/BuiltInTools/dotnet-watch/FileChangeHandler.cs b/src/BuiltInTools/dotnet-watch/FileChangeHandler.cs
new file mode 100644
index 000000000000..476f7f2993eb
--- /dev/null
+++ b/src/BuiltInTools/dotnet-watch/FileChangeHandler.cs
@@ -0,0 +1,50 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.DotNet.Watcher.Tools
+{
+ public class FileChangeHandler
+ {
+ private static readonly JsonSerializerOptions JsonSerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+ private readonly IReporter _reporter;
+
+ public FileChangeHandler(IReporter reporter)
+ {
+ _reporter = reporter;
+ }
+
+ internal async ValueTask TryHandleFileAction(DotNetWatchContext context, FileItem file, CancellationToken cancellationToken)
+ {
+ if (!file.IsStaticFile || context.BrowserRefreshServer is null)
+ {
+ return false;
+ }
+
+ _reporter.Verbose($"Handling file change event for static content {file.FilePath}.");
+ await HandleBrowserRefresh(context.BrowserRefreshServer, file, cancellationToken);
+ return true;
+ }
+
+ private static async Task HandleBrowserRefresh(BrowserRefreshServer browserRefreshServer, FileItem fileItem, CancellationToken cancellationToken)
+ {
+ var message = JsonSerializer.SerializeToUtf8Bytes(new UpdateStaticFileMessage { Path = fileItem.StaticWebAssetPath }, JsonSerializerOptions);
+ await browserRefreshServer.SendMessage(message, cancellationToken);
+ }
+
+ private readonly struct UpdateStaticFileMessage
+ {
+ public string Type => "UpdateStaticFile";
+
+ public string Path { get; init; }
+ }
+ }
+}
diff --git a/src/BuiltInTools/dotnet-watch/FileItem.cs b/src/BuiltInTools/dotnet-watch/FileItem.cs
new file mode 100644
index 000000000000..23e18ce3899c
--- /dev/null
+++ b/src/BuiltInTools/dotnet-watch/FileItem.cs
@@ -0,0 +1,16 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.DotNet.Watcher
+{
+ public readonly struct FileItem
+ {
+ public string FilePath { get; init; }
+
+ public string ProjectPath { get; init; }
+
+ public bool IsStaticFile { get; init; }
+
+ public string StaticWebAssetPath { get; init; }
+ }
+}
diff --git a/src/BuiltInTools/dotnet-watch/FileSet.cs b/src/BuiltInTools/dotnet-watch/FileSet.cs
new file mode 100644
index 000000000000..28ef122a9a13
--- /dev/null
+++ b/src/BuiltInTools/dotnet-watch/FileSet.cs
@@ -0,0 +1,36 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+
+namespace Microsoft.DotNet.Watcher
+{
+ public class FileSet : IEnumerable
+ {
+ private readonly Dictionary _files;
+
+ public FileSet(bool isNetCoreApp31OrNewer, IEnumerable files)
+ {
+ IsNetCoreApp31OrNewer = isNetCoreApp31OrNewer;
+ _files = new Dictionary(StringComparer.Ordinal);
+ foreach (var item in files)
+ {
+ _files[item.FilePath] = item;
+ }
+ }
+
+ public bool TryGetValue(string filePath, out FileItem fileItem) => _files.TryGetValue(filePath, out fileItem);
+
+ public int Count => _files.Count;
+
+ public bool IsNetCoreApp31OrNewer { get; }
+
+ public static readonly FileSet Empty = new FileSet(false, Array.Empty());
+
+ public IEnumerator GetEnumerator() => _files.Values.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+}
diff --git a/src/BuiltInTools/dotnet-watch/IFileSet.cs b/src/BuiltInTools/dotnet-watch/IFileSet.cs
deleted file mode 100644
index 177dc6d00ee5..000000000000
--- a/src/BuiltInTools/dotnet-watch/IFileSet.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright (c) .NET Foundation. All rights reserved.
-// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
-
-using System.Collections.Generic;
-
-namespace Microsoft.DotNet.Watcher
-{
- public interface IFileSet : IEnumerable
- {
- bool IsNetCoreApp31OrNewer { get; }
-
- bool Contains(string filePath);
- }
-}
diff --git a/src/BuiltInTools/dotnet-watch/IFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/IFileSetFactory.cs
index 6a70c06a4cd8..1cad758ec2b3 100644
--- a/src/BuiltInTools/dotnet-watch/IFileSetFactory.cs
+++ b/src/BuiltInTools/dotnet-watch/IFileSetFactory.cs
@@ -8,6 +8,6 @@ namespace Microsoft.DotNet.Watcher
{
public interface IFileSetFactory
{
- Task CreateAsync(CancellationToken cancellationToken);
+ Task CreateAsync(CancellationToken cancellationToken);
}
-}
\ No newline at end of file
+}
diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileSet.cs b/src/BuiltInTools/dotnet-watch/Internal/FileSet.cs
deleted file mode 100644
index 3ca17027db51..000000000000
--- a/src/BuiltInTools/dotnet-watch/Internal/FileSet.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (c) .NET Foundation. All rights reserved.
-// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
-
-using System;
-using System.Collections;
-using System.Collections.Generic;
-
-namespace Microsoft.DotNet.Watcher.Internal
-{
- public class FileSet : IFileSet
- {
- private readonly HashSet _files;
-
- public FileSet(bool isNetCoreApp31OrNewer, IEnumerable files)
- {
- IsNetCoreApp31OrNewer = isNetCoreApp31OrNewer;
- _files = new HashSet(files, StringComparer.OrdinalIgnoreCase);
- }
-
- public bool Contains(string filePath) => _files.Contains(filePath);
-
- public int Count => _files.Count;
-
- public bool IsNetCoreApp31OrNewer { get; }
-
- public static IFileSet Empty = new FileSet(false, Array.Empty());
-
- public IEnumerator GetEnumerator() => _files.GetEnumerator();
- IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator();
- }
-}
diff --git a/src/BuiltInTools/dotnet-watch/Internal/FileSetWatcher.cs b/src/BuiltInTools/dotnet-watch/Internal/FileSetWatcher.cs
index 55ad0e4b10d7..b33cfe7d3ad9 100644
--- a/src/BuiltInTools/dotnet-watch/Internal/FileSetWatcher.cs
+++ b/src/BuiltInTools/dotnet-watch/Internal/FileSetWatcher.cs
@@ -12,9 +12,9 @@ namespace Microsoft.DotNet.Watcher.Internal
public class FileSetWatcher : IDisposable
{
private readonly FileWatcher _fileWatcher;
- private readonly IFileSet _fileSet;
+ private readonly FileSet _fileSet;
- public FileSetWatcher(IFileSet fileSet, IReporter reporter)
+ public FileSetWatcher(FileSet fileSet, IReporter reporter)
{
Ensure.NotNull(fileSet, nameof(fileSet));
@@ -22,23 +22,23 @@ public FileSetWatcher(IFileSet fileSet, IReporter reporter)
_fileWatcher = new FileWatcher(reporter);
}
- public async Task GetChangedFileAsync(CancellationToken cancellationToken, Action startedWatching)
+ public async Task GetChangedFileAsync(CancellationToken cancellationToken, Action startedWatching)
{
foreach (var file in _fileSet)
{
- _fileWatcher.WatchDirectory(Path.GetDirectoryName(file));
+ _fileWatcher.WatchDirectory(Path.GetDirectoryName(file.FilePath));
}
- var tcs = new TaskCompletionSource();
+ var tcs = new TaskCompletionSource();
cancellationToken.Register(() => tcs.TrySetResult(null));
- Action callback = path =>
+ void callback(string path)
{
- if (_fileSet.Contains(path))
+ if (_fileSet.TryGetValue(path, out var fileItem))
{
- tcs.TrySetResult(path);
+ tcs.TrySetResult(fileItem);
}
- };
+ }
_fileWatcher.OnFileChange += callback;
startedWatching();
@@ -48,10 +48,9 @@ public async Task GetChangedFileAsync(CancellationToken cancellationToke
return changedFile;
}
-
- public Task GetChangedFileAsync(CancellationToken cancellationToken)
+ public Task GetChangedFileAsync(CancellationToken cancellationToken)
{
- return GetChangedFileAsync(cancellationToken, () => {});
+ return GetChangedFileAsync(cancellationToken, () => { });
}
public void Dispose()
diff --git a/src/BuiltInTools/dotnet-watch/Internal/MSBuildFileSetResult.cs b/src/BuiltInTools/dotnet-watch/Internal/MSBuildFileSetResult.cs
new file mode 100644
index 000000000000..0d128b7d8fa9
--- /dev/null
+++ b/src/BuiltInTools/dotnet-watch/Internal/MSBuildFileSetResult.cs
@@ -0,0 +1,28 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Collections.Generic;
+
+namespace Microsoft.DotNet.Watcher.Internal
+{
+ public class MSBuildFileSetResult
+ {
+ public bool IsNetCoreApp31OrNewer { get; set; }
+
+ public Dictionary Projects { get; set; }
+ }
+
+ public class ProjectItems
+ {
+ public List Files { get; set; } = new();
+
+ public List StaticFiles { get; set; } = new();
+ }
+
+ public class StaticFileItem
+ {
+ public string FilePath { get; set; }
+
+ public string StaticWebAssetPath { get; set; }
+ }
+}
diff --git a/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs b/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs
index 189857065524..de77228028ab 100644
--- a/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs
+++ b/src/BuiltInTools/dotnet-watch/Internal/MsBuildFileSetFactory.cs
@@ -6,6 +6,7 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
+using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.DotNet.Cli.Utils;
@@ -21,6 +22,7 @@ public class MsBuildFileSetFactory : IFileSetFactory
private readonly string _muxerPath;
private readonly IReporter _reporter;
+ private readonly DotNetWatchOptions _dotNetWatchOptions;
private readonly string _projectFile;
private readonly OutputSink _outputSink;
private readonly ProcessRunner _processRunner;
@@ -29,15 +31,17 @@ public class MsBuildFileSetFactory : IFileSetFactory
public MsBuildFileSetFactory(
IReporter reporter,
+ DotNetWatchOptions dotNetWatchOptions,
string projectFile,
bool waitOnError,
bool trace)
- : this(new Muxer().MuxerPath, reporter, projectFile, new OutputSink(), waitOnError, trace)
+ : this(dotNetWatchOptions, new Muxer().MuxerPath, reporter, projectFile, new OutputSink(), waitOnError, trace)
{
}
// output sink is for testing
internal MsBuildFileSetFactory(
+ DotNetWatchOptions dotNetWatchOptions,
string muxerPath,
IReporter reporter,
string projectFile,
@@ -51,14 +55,16 @@ internal MsBuildFileSetFactory(
_muxerPath = muxerPath;
_reporter = reporter;
+ _dotNetWatchOptions = dotNetWatchOptions;
_projectFile = projectFile;
_outputSink = outputSink;
_processRunner = new ProcessRunner(reporter);
_buildFlags = InitializeArgs(FindTargetsFile(), trace);
+
_waitOnError = waitOnError;
}
- public async Task CreateAsync(CancellationToken cancellationToken)
+ public async Task CreateAsync(CancellationToken cancellationToken)
{
var watchList = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
try
@@ -70,17 +76,26 @@ public async Task CreateAsync(CancellationToken cancellationToken)
cancellationToken.ThrowIfCancellationRequested();
var capture = _outputSink.StartCapture();
+ var arguments = new List
+ {
+ "msbuild",
+ "/nologo",
+ _projectFile,
+ $"/p:_DotNetWatchListFile={watchList}",
+ };
+
+ if (_dotNetWatchOptions.SuppressHandlingStaticContentFiles)
+ {
+ arguments.Add("/p:DotNetWatchContentFiles=false");
+ }
+
+ arguments.AddRange(_buildFlags);
+
var processSpec = new ProcessSpec
{
Executable = _muxerPath,
WorkingDirectory = projectDir,
- Arguments = new[]
- {
- "msbuild",
- "/nologo",
- _projectFile,
- $"/p:_DotNetWatchListFile={watchList}",
- }.Concat(_buildFlags),
+ Arguments = arguments,
OutputCapture = capture
};
@@ -90,27 +105,51 @@ public async Task CreateAsync(CancellationToken cancellationToken)
if (exitCode == 0 && File.Exists(watchList))
{
- var lines = File.ReadAllLines(watchList);
- var isNetCoreApp31OrNewer = lines.FirstOrDefault() == "true";
+ using var watchFile = File.OpenRead(watchList);
+ var result = await JsonSerializer.DeserializeAsync(watchFile, cancellationToken: cancellationToken);
+
+ var fileItems = new List();
+ foreach (var project in result.Projects)
+ {
+ var value = project.Value;
+ var fileCount = value.Files.Count;
+
+ for (var i = 0; i < fileCount; i++)
+ {
+ fileItems.Add(new FileItem
+ {
+ FilePath = value.Files[i],
+ ProjectPath = project.Key,
+ });
+ }
+
+ var staticItemsCount = value.StaticFiles.Count;
+ for (var i = 0; i < staticItemsCount; i++)
+ {
+ var item = value.StaticFiles[i];
+ fileItems.Add(new FileItem
+ {
+ FilePath = item.FilePath,
+ ProjectPath = project.Key,
+ IsStaticFile = true,
+ StaticWebAssetPath = item.StaticWebAssetPath,
+ });
+ }
+ }
- var fileset = new FileSet(
- isNetCoreApp31OrNewer,
- lines.Skip(1)
- .Select(l => l?.Trim())
- .Where(l => !string.IsNullOrEmpty(l)));
- _reporter.Verbose($"Watching {fileset.Count} file(s) for changes");
+ _reporter.Verbose($"Watching {fileItems.Count} file(s) for changes");
#if DEBUG
- foreach (var file in fileset)
+ foreach (var file in fileItems)
{
- _reporter.Verbose($" -> {file}");
+ _reporter.Verbose($" -> {file.FilePath} {(file.IsStaticFile ? file.StaticWebAssetPath : null)}");
}
- Debug.Assert(fileset.All(Path.IsPathRooted), "All files should be rooted paths");
+ Debug.Assert(fileItems.All(f => Path.IsPathRooted(f.FilePath)), "All files should be rooted paths");
#endif
- return fileset;
+ return new FileSet(result.IsNetCoreApp31OrNewer, fileItems);
}
_reporter.Error($"Error(s) finding watch items project file '{Path.GetFileName(_projectFile)}'");
@@ -133,7 +172,7 @@ public async Task CreateAsync(CancellationToken cancellationToken)
{
_reporter.Warn("Fix the error to continue or press Ctrl+C to exit.");
- var fileSet = new FileSet(false, new[] { _projectFile });
+ var fileSet = new FileSet(false, new[] { new FileItem { FilePath = _projectFile } });
using (var watcher = new FileSetWatcher(fileSet, _reporter))
{
@@ -180,6 +219,8 @@ private string FindTargetsFile()
var assemblyDir = Path.GetDirectoryName(typeof(MsBuildFileSetFactory).Assembly.Location);
var searchPaths = new[]
{
+ Path.Combine(AppContext.BaseDirectory, "assets"),
+ Path.Combine(assemblyDir, "assets"),
AppContext.BaseDirectory,
assemblyDir,
};
diff --git a/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs b/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs
index 713a40bd4435..ee9d31497587 100644
--- a/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs
+++ b/src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs
@@ -67,7 +67,7 @@ public async Task RunAsync(ProcessSpec processSpec, CancellationToken cance
stopwatch.Start();
process.Start();
- _reporter.Verbose($"Started '{processSpec.Executable}' with process id {process.Id}");
+ _reporter.Verbose($"Started '{processSpec.Executable}' '{process.StartInfo.Arguments}' with process id {process.Id}");
if (readOutput)
{
diff --git a/src/BuiltInTools/dotnet-watch/LaunchBrowserFilter.cs b/src/BuiltInTools/dotnet-watch/LaunchBrowserFilter.cs
index 2d6a83af6255..ef1648ad540f 100644
--- a/src/BuiltInTools/dotnet-watch/LaunchBrowserFilter.cs
+++ b/src/BuiltInTools/dotnet-watch/LaunchBrowserFilter.cs
@@ -5,8 +5,6 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
-using System.Runtime.InteropServices;
-using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
@@ -17,8 +15,6 @@ namespace Microsoft.DotNet.Watcher.Tools
{
public sealed class LaunchBrowserFilter : IWatchFilter, IAsyncDisposable
{
- private readonly byte[] ReloadMessage = Encoding.UTF8.GetBytes("Reload");
- private readonly byte[] WaitMessage = Encoding.UTF8.GetBytes("Wait");
private static readonly Regex NowListeningRegex = new Regex(@"^\s*Now listening on: (?.*)$", RegexOptions.None | RegexOptions.Compiled, TimeSpan.FromSeconds(10));
private readonly bool _runningInTest;
private readonly bool _suppressLaunchBrowser;
@@ -32,15 +28,12 @@ public sealed class LaunchBrowserFilter : IWatchFilter, IAsyncDisposable
private string _launchPath;
private CancellationToken _cancellationToken;
- public LaunchBrowserFilter()
+ public LaunchBrowserFilter(DotNetWatchOptions dotNetWatchOptions)
{
- var suppressLaunchBrowser = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER");
- _suppressLaunchBrowser = (suppressLaunchBrowser == "1" || suppressLaunchBrowser == "true");
+ _suppressLaunchBrowser = dotNetWatchOptions.SuppressLaunchBrowser;
+ _suppressBrowserRefresh = dotNetWatchOptions.SuppressBrowserRefresh;
+ _runningInTest = dotNetWatchOptions.RunningAsTest;
- var suppressBrowserRefresh = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_BROWSER_REFRESH");
- _suppressBrowserRefresh = (suppressBrowserRefresh == "1" || suppressBrowserRefresh == "true");
-
- _runningInTest = Environment.GetEnvironmentVariable("__DOTNET_WATCH_RUNNING_AS_TEST") == "true";
_browserPath = Environment.GetEnvironmentVariable("DOTNET_WATCH_BROWSER_PATH");
}
@@ -68,6 +61,7 @@ public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToke
if (!_suppressBrowserRefresh)
{
_refreshServer = new BrowserRefreshServer(context.Reporter);
+ context.BrowserRefreshServer = _refreshServer;
var serverUrl = await _refreshServer.StartAsync(cancellationToken);
context.Reporter.Verbose($"Refresh server running at {serverUrl}.");
@@ -82,20 +76,10 @@ public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToke
else if (!_suppressBrowserRefresh)
{
// We've detected a change. Notify the browser.
- await SendMessage(WaitMessage, cancellationToken);
+ await (_refreshServer?.SendWaitMessageAsync(cancellationToken) ?? default);
}
}
- private Task SendMessage(byte[] message, CancellationToken cancellationToken)
- {
- if (_refreshServer is null)
- {
- return Task.CompletedTask;
- }
-
- return _refreshServer.SendMessage(message, cancellationToken);
- }
-
private void OnOutput(object sender, DataReceivedEventArgs eventArgs)
{
if (string.IsNullOrEmpty(eventArgs.Data))
@@ -140,7 +124,7 @@ private void OnOutput(object sender, DataReceivedEventArgs eventArgs)
else
{
_reporter.Verbose("Reloading browser.");
- _ = SendMessage(ReloadMessage, _cancellationToken);
+ _ = _refreshServer?.ReloadAsync(_cancellationToken);
}
}
}
@@ -235,4 +219,4 @@ public async ValueTask DisposeAsync()
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/BuiltInTools/dotnet-watch/MSBuildEvaluationFilter.cs b/src/BuiltInTools/dotnet-watch/MSBuildEvaluationFilter.cs
index 82cda1dea62c..46d3c025fec4 100644
--- a/src/BuiltInTools/dotnet-watch/MSBuildEvaluationFilter.cs
+++ b/src/BuiltInTools/dotnet-watch/MSBuildEvaluationFilter.cs
@@ -56,7 +56,7 @@ public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToke
private bool RequiresMSBuildRevaluation(DotNetWatchContext context)
{
var changedFile = context.ChangedFile;
- if (!string.IsNullOrEmpty(changedFile) && IsMsBuildFileExtension(changedFile))
+ if (changedFile != null && IsMsBuildFileExtension(changedFile.Value.FilePath))
{
return true;
}
@@ -85,9 +85,9 @@ private bool RequiresMSBuildRevaluation(DotNetWatchContext context)
var msbuildFiles = new List<(string fileName, DateTime lastModifiedUtc)>();
foreach (var file in context.FileSet)
{
- if (!string.IsNullOrEmpty(file) && IsMsBuildFileExtension(file))
+ if (!string.IsNullOrEmpty(file.FilePath) && IsMsBuildFileExtension(file.FilePath))
{
- msbuildFiles.Add((file, GetLastWriteTimeUtcSafely(file)));
+ msbuildFiles.Add((file.FilePath, GetLastWriteTimeUtcSafely(file.FilePath)));
}
}
diff --git a/src/BuiltInTools/dotnet-watch/Program.cs b/src/BuiltInTools/dotnet-watch/Program.cs
index 5d89f72c357e..5d0d4c732871 100644
--- a/src/BuiltInTools/dotnet-watch/Program.cs
+++ b/src/BuiltInTools/dotnet-watch/Program.cs
@@ -223,7 +223,10 @@ private async Task MainInternalAsync(
return 1;
}
+ var watchOptions = DotNetWatchOptions.Default;
+
var fileSetFactory = new MsBuildFileSetFactory(reporter,
+ watchOptions,
projectFile,
waitOnError: true,
trace: false);
@@ -243,7 +246,7 @@ private async Task MainInternalAsync(
_reporter.Output("Polling file watcher is enabled");
}
- await using var watcher = new DotNetWatcher(reporter, fileSetFactory);
+ await using var watcher = new DotNetWatcher(reporter, fileSetFactory, watchOptions);
await watcher.WatchAsync(processInfo, cancellationToken);
return 0;
@@ -266,7 +269,9 @@ private async Task ListFilesAsync(
return 1;
}
- var fileSetFactory = new MsBuildFileSetFactory(reporter,
+ var fileSetFactory = new MsBuildFileSetFactory(
+ reporter,
+ DotNetWatchOptions.Default,
projectFile,
waitOnError: false,
trace: false);
@@ -279,7 +284,7 @@ private async Task ListFilesAsync(
foreach (var file in files)
{
- _console.Out.WriteLine(file);
+ _console.Out.WriteLine(file.FilePath);
}
return 0;
diff --git a/src/BuiltInTools/dotnet-watch/README.md b/src/BuiltInTools/dotnet-watch/README.md
index 08c8786fc2ee..922d5128c67b 100644
--- a/src/BuiltInTools/dotnet-watch/README.md
+++ b/src/BuiltInTools/dotnet-watch/README.md
@@ -32,11 +32,13 @@ Some configuration options can be passed to `dotnet watch` through environment v
| DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM | By default, `dotnet watch` optimizes the build by avoiding certain operations such as running restore or re-evaluating the set of watched files on every file change. If set to "1" or "true", these optimizations are disabled. |
| DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER | `dotnet watch run` will attempt to launch browsers for web apps with `launchBrowser` configured in `launchSettings.json`. If set to "1" or "true", this behavior is suppressed. |
| DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM | `dotnet watch run` will attempt to refresh browsers when it detects file changes. If set to "1" or "true", this behavior is suppressed. This behavior is also suppressed if DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER is set. |
+| DOTNET_WATCH_SUPPRESS_STATIC_FILE_HANDLING | If set to "1", or "true", `dotnet watch` will not perform special handling for static content file
+
### MSBuild
dotnet-watch can be configured from the MSBuild project file being watched.
-#### Watch items
+**Watch items**
dotnet-watch will watch all items in the **Watch** item group.
By default, this group inclues all items in **Compile** and **EmbeddedResource**.
@@ -76,7 +78,7 @@ dotnet-watch will ignore project references with the `Watch="false"` attribute.
```
-#### Advanced configuration
+**Advanced configuration**
dotnet-watch performs a design-time build to find items to watch.
When this build is run, dotnet-watch will set the property `DotNetWatchBuild=true`.
@@ -88,6 +90,7 @@ Example:
```
+
## Contribution
Follow the contribution steps for the dotnet SDK: /documentation/project-docs/developer-guide.md. If developing from Visual Studio, open the dotnet-watch.slnf.
diff --git a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj
index 7db469703cde..2f9220d30eac 100644
--- a/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj
+++ b/src/BuiltInTools/dotnet-watch/dotnet-watch.csproj
@@ -27,14 +27,13 @@
ReferenceOutputAssembly="false"
SkipGetTargetFrameworkProperties="true"
UndefineProperties="TargetFramework;TargetFrameworks" />
-
-
-
-
-
-
+
+
diff --git a/src/BuiltInTools/dotnet-watch/dotnet-watch.slnf b/src/BuiltInTools/dotnet-watch/dotnet-watch.slnf
index 9a2e422ae688..d5e3affaeda2 100644
--- a/src/BuiltInTools/dotnet-watch/dotnet-watch.slnf
+++ b/src/BuiltInTools/dotnet-watch/dotnet-watch.slnf
@@ -5,6 +5,7 @@
"src\\Tests\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj",
"src\\Tests\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj",
"src\\Tests\\dotnet-watch.Tests\\dotnet-watch.Tests.csproj",
+ "src\\Cli\\Microsoft.DotNet.Cli.Utils\\Microsoft.DotNet.Cli.Utils.csproj",
"src\\BuiltInTools\\BrowserRefresh\\Microsoft.AspNetCore.Watch.BrowserRefresh.csproj",
"src\\BuiltInTools\\dotnet-watch\\dotnet-watch.csproj"
]
diff --git a/src/Layout/redist/targets/GenerateLayout.targets b/src/Layout/redist/targets/GenerateLayout.targets
index 5d3881b61ee6..20b3aac0186c 100644
--- a/src/Layout/redist/targets/GenerateLayout.targets
+++ b/src/Layout/redist/targets/GenerateLayout.targets
@@ -129,6 +129,7 @@
+
+
(NullReporter.Singleton);
+ byte[] writtenBytes = null;
+ server.Setup(s => s.SendMessage(It.IsAny(), It.IsAny()))
+ .Callback((byte[] bytes, CancellationToken cts) =>
+ {
+ writtenBytes = bytes;
+ });
+ var fileContentHandler = new FileChangeHandler(NullReporter.Singleton);
+ var context = new DotNetWatchContext
+ {
+ BrowserRefreshServer = server.Object,
+ };
+ var file = new FileItem { FilePath = "Test.css", IsStaticFile = true, StaticWebAssetPath = "content/Test.css" };
+
+ // Act
+ var result = await fileContentHandler.TryHandleFileAction(context, file, default);
+
+ // Assert
+ Assert.True(result);
+ Assert.NotNull(writtenBytes);
+ var deserialized = JsonSerializer.Deserialize(writtenBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
+ Assert.Equal("UpdateStaticFile", deserialized.Type);
+ Assert.Equal("content/Test.css", deserialized.Path);
+ }
+
+ [Fact]
+ public async ValueTask TryHandleFileAction_CausesBrowserRefreshForNonCssFile()
+ {
+ // Arrange
+ var server = new Mock(NullReporter.Singleton);
+ byte[] writtenBytes = null;
+ server.Setup(s => s.SendMessage(It.IsAny(), It.IsAny()))
+ .Callback((byte[] bytes, CancellationToken cts) =>
+ {
+ writtenBytes = bytes;
+ });
+ var fileContentHandler = new FileChangeHandler(NullReporter.Singleton);
+ var context = new DotNetWatchContext
+ {
+ BrowserRefreshServer = server.Object,
+ };
+ var file = new FileItem { FilePath = "Test.js", IsStaticFile = true, StaticWebAssetPath = "Test.js" };
+
+ // Act
+ var result = await fileContentHandler.TryHandleFileAction(context, file, default);
+
+ // Assert
+ Assert.True(result);
+ Assert.NotNull(writtenBytes);
+ var deserialized = JsonSerializer.Deserialize(writtenBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
+ Assert.Equal("UpdateStaticFile", deserialized.Type);
+ Assert.Equal("content/Test.js", deserialized.Path);
+ }
+
+ private record UpdateStaticFileMessage(string Type, string Path);
+
+ }
+}
diff --git a/src/Tests/dotnet-watch.Tests/MSBuildEvaluationFilterTest.cs b/src/Tests/dotnet-watch.Tests/MSBuildEvaluationFilterTest.cs
index 5933b74556f3..7f8338ded426 100644
--- a/src/Tests/dotnet-watch.Tests/MSBuildEvaluationFilterTest.cs
+++ b/src/Tests/dotnet-watch.Tests/MSBuildEvaluationFilterTest.cs
@@ -5,7 +5,6 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
-using Microsoft.DotNet.Watcher.Internal;
using Moq;
using Xunit;
@@ -14,7 +13,7 @@ namespace Microsoft.DotNet.Watcher.Tools
public class MSBuildEvaluationFilterTest
{
private readonly IFileSetFactory _fileSetFactory = Mock.Of(
- f => f.CreateAsync(It.IsAny()) == Task.FromResult(FileSet.Empty));
+ f => f.CreateAsync(It.IsAny()) == Task.FromResult(FileSet.Empty));
[Fact]
public async Task ProcessAsync_EvaluatesFileSetIfProjFileChanges()
@@ -29,7 +28,7 @@ public async Task ProcessAsync_EvaluatesFileSetIfProjFileChanges()
await filter.ProcessAsync(context, default);
context.Iteration++;
- context.ChangedFile = "Test.csproj";
+ context.ChangedFile = new FileItem { FilePath = "Test.csproj" };
context.RequiresMSBuildRevaluation = false;
// Act
@@ -52,7 +51,7 @@ public async Task ProcessAsync_DoesNotEvaluateFileSetIfNonProjFileChanges()
await filter.ProcessAsync(context, default);
context.Iteration++;
- context.ChangedFile = "Controller.cs";
+ context.ChangedFile = new FileItem { FilePath = "Controller.cs" };
context.RequiresMSBuildRevaluation = false;
// Act
@@ -77,7 +76,7 @@ public async Task ProcessAsync_EvaluateFileSetOnEveryChangeIfOptimizationIsSuppr
await filter.ProcessAsync(context, default);
context.Iteration++;
- context.ChangedFile = "Controller.cs";
+ context.ChangedFile = new FileItem { FilePath = "Controller.cs" };
context.RequiresMSBuildRevaluation = false;
// Act
@@ -96,8 +95,8 @@ public async Task ProcessAsync_SetsEvaluationRequired_IfMSBuildFileChanges_ButIs
// concurrent edits. MSBuildEvaluationFilter uses timestamps to additionally track changes to these files.
// Arrange
- var fileSet = new FileSet(false, new[] { "Controlller.cs", "Proj.csproj" });
- var fileSetFactory = Mock.Of(f => f.CreateAsync(It.IsAny()) == Task.FromResult(fileSet));
+ var fileSet = new FileSet(false, new[] { new FileItem { FilePath = "Controlller.cs" }, new FileItem { FilePath = "Proj.csproj" } });
+ var fileSetFactory = Mock.Of(f => f.CreateAsync(It.IsAny()) == Task.FromResult(fileSet));
var filter = new TestableMSBuildEvaluationFilter(fileSetFactory)
{
@@ -114,7 +113,7 @@ public async Task ProcessAsync_SetsEvaluationRequired_IfMSBuildFileChanges_ButIs
await filter.ProcessAsync(context, default);
context.RequiresMSBuildRevaluation = false;
- context.ChangedFile = "Controller.cs";
+ context.ChangedFile = new FileItem { FilePath = "Controller.cs" };
context.Iteration++;
filter.Timestamps["Proj.csproj"] = new DateTime(1007);
diff --git a/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs b/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs
index 801af97ad67e..09da85040348 100644
--- a/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs
+++ b/src/Tests/dotnet-watch.Tests/MsBuildFileSetFactoryTest.cs
@@ -165,6 +165,83 @@ public async Task MultiTfm()
);
}
+ [Fact]
+ public async Task IncludesContentFiles()
+ {
+ var testDir = _testAssets.CreateTestDirectory();
+
+ var project = WriteFile(testDir, Path.Combine("Project1.csproj"),
+@"
+
+ netstandard2.1
+
+");
+ WriteFile(testDir, Path.Combine("Program.cs"));
+
+ WriteFile(testDir, Path.Combine("wwwroot", "css", "app.css"));
+ WriteFile(testDir, Path.Combine("wwwroot", "js", "site.js"));
+ WriteFile(testDir, Path.Combine("wwwroot", "favicon.ico"));
+
+ var fileset = await GetFileSet(project);
+
+ AssertEx.EqualFileList(
+ testDir.Path,
+ new[]
+ {
+ "Project1.csproj",
+ "Program.cs",
+ "wwwroot/css/app.css",
+ "wwwroot/js/site.js",
+ "wwwroot/favicon.ico",
+ },
+ fileset
+ );
+ }
+
+ [Fact]
+ public async Task IncludesContentFilesFromRCL()
+ {
+ var testDir = _testAssets.CreateTestDirectory();
+ WriteFile(testDir, Path.Combine("RCL1", "RCL1.csproj"),
+@"
+
+ netcoreapp5.0
+
+
+");
+ WriteFile(testDir, Path.Combine("RCL1", "wwwroot", "css", "app.css"));
+ WriteFile(testDir, Path.Combine("RCL1", "wwwroot", "js", "site.js"));
+ WriteFile(testDir, Path.Combine("RCL1", "wwwroot", "favicon.ico"));
+
+ var projectPath = WriteFile(testDir, Path.Combine("Project1", "Project1.csproj"),
+@"
+
+ netstandard2.1
+
+
+
+
+");
+ WriteFile(testDir, Path.Combine("Project1", "Program.cs"));
+
+
+ var fileset = await GetFileSet(projectPath);
+
+ AssertEx.EqualFileList(
+ testDir.Path,
+ new[]
+ {
+ "Project1/Project1.csproj",
+ "Project1/Program.cs",
+ "RCL1/RCL1.csproj",
+ "RCL1/wwwroot/css/app.css",
+ "RCL1/wwwroot/js/site.js",
+ "RCL1/wwwroot/favicon.ico",
+ },
+ fileset
+ );
+ }
+
[Fact]
public async Task ProjectReferences_OneLevel()
{
@@ -229,6 +306,8 @@ public async Task TransitiveProjectReferences_TwoLevels()
},
fileset
);
+
+ Assert.All(fileset, f => Assert.False(f.IsStaticFile, $"File {f.FilePath} should not be a static file."));
}
[Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/29213")]
@@ -248,7 +327,8 @@ public async Task ProjectReferences_Graph()
var projectA = Path.Combine(testDirectory, "A", "A.csproj");
var output = new OutputSink();
- var filesetFactory = new MsBuildFileSetFactory(DotNetHostPath, _reporter, projectA, output, waitOnError: false, trace: true);
+ var options = GetWatchOptions();
+ var filesetFactory = new MsBuildFileSetFactory(options, DotNetHostPath, _reporter, projectA, output, waitOnError: false, trace: true);
var fileset = await GetFileSet(filesetFactory);
@@ -274,26 +354,46 @@ public async Task ProjectReferences_Graph()
);
}
- private Task GetFileSet(TestAsset target)
+ private Task GetFileSet(TestAsset target)
+ {
+ var projectPath = GetTestProjectPath(target);
+ return GetFileSet(projectPath);
+ }
+
+ private Task GetFileSet(string projectPath)
{
- string projectPath = GetTestProjectPath(target);
- return GetFileSet(new MsBuildFileSetFactory(DotNetHostPath, _reporter, projectPath, new OutputSink(), waitOnError: false, trace: false));
+ DotNetWatchOptions options = GetWatchOptions();
+ return GetFileSet(new MsBuildFileSetFactory(options, DotNetHostPath, _reporter, projectPath, new OutputSink(), waitOnError: false, trace: false));
}
+ private static DotNetWatchOptions GetWatchOptions() =>
+ new DotNetWatchOptions(false, false, false, false, false);
+
private static string GetTestProjectPath(TestAsset target) => Path.Combine(GetTestProjectDirectory(target), target.TestProject.Name + ".csproj");
- private async Task GetFileSet(MsBuildFileSetFactory filesetFactory)
+ private async Task GetFileSet(MsBuildFileSetFactory filesetFactory)
{
return await filesetFactory
.CreateAsync(CancellationToken.None)
.TimeoutAfter(TimeSpan.FromSeconds(30));
}
- private static void WriteFile(TestAsset testAsset, string name, string contents = "")
+ private static string WriteFile(TestAsset testAsset, string name, string contents = "")
{
var path = Path.Combine(GetTestProjectDirectory(testAsset), name);
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, contents);
+
+ return path;
+ }
+
+ private static string WriteFile(TestDirectory testAsset, string name, string contents = "")
+ {
+ var path = Path.Combine(testAsset.Path, name);
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+ File.WriteAllText(path, contents);
+
+ return path;
}
private static string GetTestProjectDirectory(TestAsset testAsset)
diff --git a/src/Tests/dotnet-watch.Tests/NoRestoreFilterTest.cs b/src/Tests/dotnet-watch.Tests/NoRestoreFilterTest.cs
index 067671570791..0d3a9eff4e3a 100644
--- a/src/Tests/dotnet-watch.Tests/NoRestoreFilterTest.cs
+++ b/src/Tests/dotnet-watch.Tests/NoRestoreFilterTest.cs
@@ -47,7 +47,7 @@ public async Task ProcessAsync_LeavesArgumentsUnchangedIfMsBuildRevaluationIsReq
};
await filter.ProcessAsync(context, default);
- context.ChangedFile = "Test.proj";
+ context.ChangedFile = new FileItem { FilePath = "Test.proj" };
context.RequiresMSBuildRevaluation = true;
context.Iteration++;
@@ -75,7 +75,7 @@ public async Task ProcessAsync_LeavesArgumentsUnchangedIfOptimizationIsSuppresse
};
await filter.ProcessAsync(context, default);
- context.ChangedFile = "Program.cs";
+ context.ChangedFile = new FileItem { FilePath = "Program.cs" };
context.Iteration++;
// Act
@@ -101,7 +101,7 @@ public async Task ProcessAsync_AddsNoRestoreSwitch()
};
await filter.ProcessAsync(context, default);
- context.ChangedFile = "Program.cs";
+ context.ChangedFile = new FileItem { FilePath = "Program.cs" };
context.Iteration++;
// Act
@@ -127,7 +127,7 @@ public async Task ProcessAsync_AddsNoRestoreSwitch_WithAdditionalArguments()
};
await filter.ProcessAsync(context, default);
- context.ChangedFile = "Program.cs";
+ context.ChangedFile = new FileItem { FilePath = "Program.cs" };
context.Iteration++;
// Act
@@ -153,7 +153,7 @@ public async Task ProcessAsync_AddsNoRestoreSwitch_ForTestCommand()
};
await filter.ProcessAsync(context, default);
- context.ChangedFile = "Program.cs";
+ context.ChangedFile = new FileItem { FilePath = "Program.cs" };
context.Iteration++;
// Act
@@ -180,7 +180,7 @@ public async Task ProcessAsync_DoesNotModifyArgumentsForUnknownCommands()
};
await filter.ProcessAsync(context, default);
- context.ChangedFile = "Program.cs";
+ context.ChangedFile = new FileItem { FilePath = "Program.cs" };
context.Iteration++;
// Act
diff --git a/src/Tests/dotnet-watch.Tests/Utilities/AssertEx.cs b/src/Tests/dotnet-watch.Tests/Utilities/AssertEx.cs
index 4b48224ba30a..191a510a467c 100644
--- a/src/Tests/dotnet-watch.Tests/Utilities/AssertEx.cs
+++ b/src/Tests/dotnet-watch.Tests/Utilities/AssertEx.cs
@@ -8,8 +8,11 @@
namespace Microsoft.DotNet.Watcher.Tools
{
- internal static class AssertEx
+ public static class AssertEx
{
+ public static void EqualFileList(string root, IEnumerable expectedFiles, FileSet actualFiles)
+ => EqualFileList(root, expectedFiles, actualFiles.Select(f => f.FilePath));
+
public static void EqualFileList(string root, IEnumerable expectedFiles, IEnumerable actualFiles)
{
var expected = expectedFiles.Select(p => Path.Combine(root, p));
@@ -24,10 +27,41 @@ public static void EqualFileList(IEnumerable expectedFiles, IEnumerable<
if (!expected.SetEquals(actual))
{
throw new AssertActualExpectedException(
- expected: "\n" + string.Join("\n", expected),
- actual: "\n" + string.Join("\n", actual),
+ expected: "\n" + string.Join("\n", expected.OrderBy(p => p)),
+ actual: "\n" + string.Join("\n", actual.OrderBy(p => p)),
userMessage: "File sets should be equal");
}
}
+
+ public static void EqualFileList(FileSet expectedFiles, FileSet actualFiles)
+ {
+ if (expectedFiles.Count != actualFiles.Count)
+ {
+ throw new AssertCollectionCountException(expectedFiles.Count, actualFiles.Count);
+ }
+
+ foreach (var expected in expectedFiles)
+ {
+ var actual = actualFiles.FirstOrDefault(f => Normalize(expected.FilePath) == Normalize(f.FilePath));
+
+ if (actual.FilePath is null)
+ {
+ throw new AssertActualExpectedException(
+ expected: $"Expected to find {expected.FilePath}.",
+ actual: "\n" + string.Join("\n", actualFiles.Select(f => f.FilePath)),
+ userMessage: "File sets should be equal.");
+ }
+
+ if (expected.IsStaticFile != actual.IsStaticFile || expected.StaticWebAssetPath != actual.StaticWebAssetPath)
+ {
+ throw new AssertActualExpectedException(
+ expected: $"FileKind: {expected.IsStaticFile} StaticWebAssetPath {expected.StaticWebAssetPath}",
+ actual: $"FileKind: {actual.IsStaticFile} StaticWebAssetPath {actual.StaticWebAssetPath}",
+ userMessage: "Flle sets should be equal.");
+ }
+ }
+
+ static string Normalize(string file) => file.Replace('\\', '/');
+ }
}
}
diff --git a/src/Tests/dotnet-watch.Tests/dotnet-watch.Tests.csproj b/src/Tests/dotnet-watch.Tests/dotnet-watch.Tests.csproj
index 46b1cfa8162d..c126799ec11f 100644
--- a/src/Tests/dotnet-watch.Tests/dotnet-watch.Tests.csproj
+++ b/src/Tests/dotnet-watch.Tests/dotnet-watch.Tests.csproj
@@ -7,6 +7,7 @@
+