Skip to content

Commit

Permalink
File watcher improvements (#45131)
Browse files Browse the repository at this point in the history
  • Loading branch information
tmat authored Dec 20, 2024
1 parent 79f2919 commit b810aa4
Show file tree
Hide file tree
Showing 67 changed files with 1,792 additions and 368 deletions.
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

0 comments on commit b810aa4

Please sign in to comment.