Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/BuiltInTools/dotnet-watch/Browser/BrowserConnector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public bool TryGetRefreshServer(ProjectGraphNode projectNode, [NotNullWhen(true)
{
lock (_serversGuard)
{
return _servers.TryGetValue(projectNode, out server);
return _servers.TryGetValue(projectNode, out server) && server != null;
}
}

Expand Down
5 changes: 1 addition & 4 deletions src/BuiltInTools/dotnet-watch/FileItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,12 @@ internal readonly record struct FileItem

/// <summary>
/// List of all projects that contain this file (does not contain duplicates).
/// Empty if <see cref="Change"/> is <see cref="ChangeKind.Add"/> and the
/// item has not been assigned to a project yet.
/// Empty if the item is added but not been assigned to a project yet.
/// </summary>
public required List<string> ContainingProjectPaths { get; init; }

public string? StaticWebAssetPath { get; init; }

public ChangeKind Change { get; init; }

public bool IsStaticFile => StaticWebAssetPath != null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates update
switch (updates.Status)
{
case ModuleUpdateStatus.None:
_reporter.Output("No C# changes to apply.");
_reporter.Report(MessageDescriptor.NoHotReloadChangesToApply);
break;

case ModuleUpdateStatus.Ready:
Expand Down
278 changes: 209 additions & 69 deletions src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs

Large diffs are not rendered by default.

50 changes: 29 additions & 21 deletions src/BuiltInTools/dotnet-watch/Internal/FileWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ internal sealed class FileWatcher(IReporter reporter) : IDisposable
private readonly Dictionary<string, IDirectoryWatcher> _watchers = [];

private bool _disposed;
public event Action<string, ChangeKind>? OnFileChange;
public event Action<ChangedPath>? OnFileChange;

public bool SuppressEvents { get; set; }

public void Dispose()
{
Expand Down Expand Up @@ -62,6 +64,11 @@ public void WatchDirectories(IEnumerable<string> directories)
}

var newWatcher = FileWatcherFactory.CreateWatcher(directory);
if (newWatcher is EventBasedDirectoryWatcher eventBasedWatcher)
{
eventBasedWatcher.Logger = message => reporter.Verbose(message);
}

newWatcher.OnFileChange += WatcherChangedHandler;
newWatcher.OnError += WatcherErrorHandler;
newWatcher.EnableRaisingEvents = true;
Expand All @@ -78,9 +85,12 @@ private void WatcherErrorHandler(object? sender, Exception error)
}
}

private void WatcherChangedHandler(object? sender, (string changedPath, ChangeKind kind) args)
private void WatcherChangedHandler(object? sender, ChangedPath change)
{
OnFileChange?.Invoke(args.changedPath, args.kind);
if (!SuppressEvents)
{
OnFileChange?.Invoke(change);
}
}

private void DisposeWatcher(string directory)
Expand All @@ -98,45 +108,43 @@ private void DisposeWatcher(string directory)
private static string EnsureTrailingSlash(string path)
=> (path is [.., var last] && last != Path.DirectorySeparatorChar) ? path + Path.DirectorySeparatorChar : path;

public Task<ChangedFile?> WaitForFileChangeAsync(Action? startedWatching, CancellationToken cancellationToken)
=> WaitForFileChangeAsync(
changeFilter: (path, kind) => new ChangedFile(new FileItem() { FilePath = path, ContainingProjectPaths = [] }, kind),
startedWatching,
cancellationToken);

public Task<ChangedFile?> WaitForFileChangeAsync(IReadOnlyDictionary<string, FileItem> fileSet, Action? startedWatching, CancellationToken cancellationToken)
=> WaitForFileChangeAsync(
changeFilter: (path, kind) => fileSet.TryGetValue(path, out var fileItem) ? new ChangedFile(fileItem, kind) : null,
public async Task<ChangedFile?> WaitForFileChangeAsync(IReadOnlyDictionary<string, FileItem> fileSet, Action? startedWatching, CancellationToken cancellationToken)
{
var changedPath = await WaitForFileChangeAsync(
acceptChange: change => fileSet.ContainsKey(change.Path),
startedWatching,
cancellationToken);

public async Task<ChangedFile?> WaitForFileChangeAsync(Func<string, ChangeKind, ChangedFile?> changeFilter, Action? startedWatching, CancellationToken cancellationToken)
return changedPath.HasValue ? new ChangedFile(fileSet[changedPath.Value.Path], changedPath.Value.Kind) : null;
}

public async Task<ChangedPath?> WaitForFileChangeAsync(Predicate<ChangedPath> acceptChange, Action? startedWatching, CancellationToken cancellationToken)
{
var fileChangedSource = new TaskCompletionSource<ChangedFile?>(TaskCreationOptions.RunContinuationsAsynchronously);
var fileChangedSource = new TaskCompletionSource<ChangedPath?>(TaskCreationOptions.RunContinuationsAsynchronously);
cancellationToken.Register(() => fileChangedSource.TrySetResult(null));

void FileChangedCallback(string path, ChangeKind kind)
void FileChangedCallback(ChangedPath change)
{
if (changeFilter(path, kind) is { } changedFile)
if (acceptChange(change))
{
fileChangedSource.TrySetResult(changedFile);
fileChangedSource.TrySetResult(change);
}
}

ChangedFile? changedFile;
ChangedPath? change;

OnFileChange += FileChangedCallback;
try
{
startedWatching?.Invoke();
changedFile = await fileChangedSource.Task;
change = await fileChangedSource.Task;
}
finally
{
OnFileChange -= FileChangedCallback;
}

return changedFile;
return change;
}

public static async ValueTask WaitForFileChangeAsync(string filePath, IReporter reporter, Action? startedWatching, CancellationToken cancellationToken)
Expand All @@ -146,7 +154,7 @@ public static async ValueTask WaitForFileChangeAsync(string filePath, IReporter
watcher.WatchDirectories([Path.GetDirectoryName(filePath)!]);

var fileChange = await watcher.WaitForFileChangeAsync(
changeFilter: (path, kind) => path == filePath ? new ChangedFile(new FileItem { FilePath = path, ContainingProjectPaths = [] }, kind) : null,
acceptChange: change => change.Path == filePath,
startedWatching,
cancellationToken);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ internal enum ChangeKind
Delete
}

internal readonly record struct ChangedFile(FileItem Item, ChangeKind Change);
internal readonly record struct ChangedFile(FileItem Item, ChangeKind Kind);

internal readonly record struct ChangedPath(string Path, ChangeKind Kind);
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Microsoft.DotNet.Watch
{
internal sealed class EventBasedDirectoryWatcher : IDirectoryWatcher
{
public event EventHandler<(string filePath, ChangeKind kind)>? OnFileChange;
public event EventHandler<ChangedPath>? OnFileChange;

public event EventHandler<Exception>? OnError;

Expand Down Expand Up @@ -118,7 +118,7 @@ private void WatcherAddedHandler(object sender, FileSystemEventArgs e)
private void NotifyChange(string fullPath, ChangeKind kind)
{
// Only report file changes
OnFileChange?.Invoke(this, (fullPath, kind));
OnFileChange?.Invoke(this, new ChangedPath(fullPath, kind));
}

private void CreateFileSystemWatcher()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Microsoft.DotNet.Watch
{
internal interface IDirectoryWatcher : IDisposable
{
event EventHandler<(string filePath, ChangeKind kind)> OnFileChange;
event EventHandler<ChangedPath> OnFileChange;

event EventHandler<Exception> OnError;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ internal sealed class PollingDirectoryWatcher : IDirectoryWatcher

private volatile bool _disposed;

public event EventHandler<(string filePath, ChangeKind kind)>? OnFileChange;
public event EventHandler<ChangedPath>? OnFileChange;

#pragma warning disable CS0067 // not used
public event EventHandler<Exception>? OnError;
Expand Down Expand Up @@ -212,7 +212,7 @@ private void NotifyChanges()
break;
}

OnFileChange?.Invoke(this, (path, kind));
OnFileChange?.Invoke(this, new ChangedPath(path, kind));
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/BuiltInTools/dotnet-watch/Internal/IReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ public bool TryGetMessage(string? prefix, object?[] args, [NotNullWhen(true)] ou
public static readonly MessageDescriptor ApplyUpdate_FileContentDoesNotMatchBuiltSource = new("{0} Expected if a source file is updated that is linked to project whose build is not up-to-date.", "⌚", MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor ConfiguredToLaunchBrowser = new("dotnet-watch is configured to launch a browser on ASP.NET Core application startup.", "⌚", MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor ConfiguredToUseBrowserRefresh = new("Configuring the app to use browser-refresh middleware", "⌚", MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor IgnoringChangeInHiddenDirectory = new("Ignoring change in hidden directory '{0}': {1} '{2}'", "⌚", MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor IgnoringChangeInOutputDirectory = new("Ignoring change in output directory: {0} '{1}'", "⌚", MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor FileAdditionTriggeredReEvaluation = new("File addition triggered re-evaluation.", "⌚", MessageSeverity.Verbose, s_id++);
public static readonly MessageDescriptor NoHotReloadChangesToApply = new ("No C# changes to apply.", "⌚", MessageSeverity.Output, s_id++);
}

internal interface IReporter
Expand Down
45 changes: 45 additions & 0 deletions src/BuiltInTools/dotnet-watch/Utilities/PathUtilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.DotNet.Watch;

internal static class PathUtilities
{
public static readonly IEqualityComparer<string> OSSpecificPathComparer = Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;

public static bool ContainsPath(IReadOnlySet<string> directories, string fullPath)
{
fullPath = Path.TrimEndingDirectorySeparator(fullPath);

while (true)
{
if (directories.Contains(fullPath))
{
return true;
}

var containingDir = Path.GetDirectoryName(fullPath);
if (containingDir == null)
{
return false;
}

fullPath = containingDir;
}
}

public static IEnumerable<string> GetContainingDirectories(string path)
{
while (true)
{
var containingDir = Path.GetDirectoryName(path);
if (containingDir == null)
{
yield break;
}

yield return containingDir;
path = containingDir;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public static bool IsNetCoreApp(this ProjectGraphNode projectNode)
public static bool IsNetCoreApp(this ProjectGraphNode projectNode, Version minVersion)
=> IsNetCoreApp(projectNode) && IsTargetFrameworkVersionOrNewer(projectNode, minVersion);

public static string? GetOutputDirectory(this ProjectGraphNode projectNode)
=> projectNode.ProjectInstance.GetPropertyValue("TargetPath") is { Length: >0 } path ? Path.GetDirectoryName(Path.Combine(projectNode.ProjectInstance.Directory, path)) : null;

public static string? GetIntermediateOutputDirectory(this ProjectGraphNode projectNode)
=> projectNode.ProjectInstance.GetPropertyValue("IntermediateOutputPath") is { Length: >0 } path ? Path.Combine(projectNode.ProjectInstance.Directory, path) : null;

public static IEnumerable<string> GetCapabilities(this ProjectGraphNode projectNode)
=> projectNode.ProjectInstance.GetItems("ProjectCapability").Select(item => item.EvaluatedInclude);

Expand Down
1 change: 1 addition & 0 deletions test/Common/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root = true
3 changes: 2 additions & 1 deletion test/Microsoft.NET.TestFramework/SetupTestRoot.targets
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
<Project>

<ItemGroup>
<Foo Include="Bar" />
<_CopyDirectoryBuildTestDependenciesInput Include="$(MSBuildThisFileDirectory)..\Common\Empty.props" />
<_CopyDirectoryBuildTestDependenciesInput Include="$(MSBuildThisFileDirectory)..\Common\Empty.targets" />
<_CopyDirectoryBuildTestDependenciesInput Include="$(MSBuildThisFileDirectory)..\Common\.editorconfig" />
</ItemGroup>

<ItemGroup>
<_CopyDirectoryBuildTestDependenciesOutput Include="$(ArtifactsTmpDir)Directory.Build.props" />
<_CopyDirectoryBuildTestDependenciesOutput Include="$(ArtifactsTmpDir)Directory.Build.targets" />
<_CopyDirectoryBuildTestDependenciesOutput Include="$(ArtifactsTmpDir).editorconfig" />
</ItemGroup>

<!-- Since TestFramework is multi-targeted, only copy these files for one of the inner builds -->
Expand Down
10 changes: 7 additions & 3 deletions test/Microsoft.NET.TestFramework/TestAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,13 @@ public TestAsset UpdateProjProperty(string propertyName, string variableName, st
p =>
{
var ns = p.Root.Name.Namespace;
var getNode = p.Root.Elements(ns + "PropertyGroup").Elements(ns + propertyName).FirstOrDefault();
getNode ??= p.Root.Elements(ns + "PropertyGroup").Elements(ns + $"{propertyName}s").FirstOrDefault();
getNode?.SetValue(getNode?.Value.Replace($"$({variableName})", targetValue));
var nodes = p.Root.Elements(ns + "PropertyGroup").Elements(ns + propertyName).Concat(
p.Root.Elements(ns + "PropertyGroup").Elements(ns + $"{propertyName}s"));

foreach (var node in nodes)
{
node.SetValue(node.Value.Replace($"$({variableName})", targetValue));
}
});
}

Expand Down
1 change: 1 addition & 0 deletions test/TestAssets/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
root = true
17 changes: 17 additions & 0 deletions test/TestAssets/TestProjects/WatchMauiBlazor/App.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:maui_blazor"
x:Class="maui_blazor.App">
<Application.Resources>
<ResourceDictionary>

<!--
For information about styling .NET MAUI pages
please refer to the documentation:
https://go.microsoft.com/fwlink/?linkid=2282329
-->

</ResourceDictionary>
</Application.Resources>
</Application>
14 changes: 14 additions & 0 deletions test/TestAssets/TestProjects/WatchMauiBlazor/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace maui_blazor;

public partial class App : Application
{
public App()
{
InitializeComponent();
}

protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new MainPage()) { Title = "maui-blazor" };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@inherits LayoutComponentBase

<div class="page">
<div class="sidebar">
<NavMenu />
</div>

<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>

<article class="content px-4">
@Body
</article>
</main>
</div>
Loading
Loading