From 7cae9979b42aae1f857cdf5f75be9bc5722bfde6 Mon Sep 17 00:00:00 2001 From: Martin Ruiz Date: Thu, 12 Mar 2026 11:55:25 -0700 Subject: [PATCH 1/5] Update the PM UI when a successful restore finishes --- .../NuGetSolutionManagerServiceWrapper.cs | 9 ++++ .../Xamls/PackageManagerControl.xaml.cs | 44 +++++++++++++++++++ .../Services/NuGetSolutionManagerService.cs | 15 +++++++ .../Telemetry/PackageManagerUIRefreshEvent.cs | 1 + .../INuGetSolutionManagerService.cs | 1 + ...NuGetSolutionManagerServiceWrapperTests.cs | 29 ++++++++++++ .../Telemetry/NuGetTelemetryServiceTests.cs | 3 ++ 7 files changed, 102 insertions(+) diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/UserInterfaceService/NuGetSolutionManagerServiceWrapper.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/UserInterfaceService/NuGetSolutionManagerServiceWrapper.cs index bfa8f5960b8..41c25417c36 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/UserInterfaceService/NuGetSolutionManagerServiceWrapper.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/UserInterfaceService/NuGetSolutionManagerServiceWrapper.cs @@ -19,6 +19,7 @@ internal sealed class NuGetSolutionManagerServiceWrapper : INuGetSolutionManager public event EventHandler? ProjectRemoved; public event EventHandler? ProjectRenamed; public event EventHandler? ProjectUpdated; + public event EventHandler? SolutionRestoreCompleted; internal INuGetSolutionManagerService Service { @@ -73,6 +74,7 @@ private void RegisterEventHandlers() _service.ProjectRemoved += OnProjectRemoved; _service.ProjectRenamed += OnProjectRenamed; _service.ProjectUpdated += OnProjectUpdated; + _service.SolutionRestoreCompleted += OnSolutionRestoreCompleted; } private void UnregisterEventHandlers() @@ -83,6 +85,7 @@ private void UnregisterEventHandlers() _service.ProjectRemoved -= OnProjectRemoved; _service.ProjectRenamed -= OnProjectRenamed; _service.ProjectUpdated -= OnProjectUpdated; + _service.SolutionRestoreCompleted -= OnSolutionRestoreCompleted; } private void OnAfterNuGetCacheUpdated(object sender, string e) @@ -115,6 +118,11 @@ private void OnProjectUpdated(object sender, IProjectContextInfo e) ProjectUpdated?.Invoke(this, e); } + private void OnSolutionRestoreCompleted(object sender, bool e) + { + SolutionRestoreCompleted?.Invoke(this, e); + } + private sealed class NullNuGetSolutionManagerService : INuGetSolutionManagerService { public event EventHandler AfterNuGetCacheUpdated { add { } remove { } } @@ -123,6 +131,7 @@ public event EventHandler ProjectAdded { add { } remove { } public event EventHandler ProjectRemoved { add { } remove { } } public event EventHandler ProjectRenamed { add { } remove { } } public event EventHandler ProjectUpdated { add { } remove { } } + public event EventHandler SolutionRestoreCompleted { add { } remove { } } internal static NullNuGetSolutionManagerService Instance { get; } = new NullNuGetSolutionManagerService(); diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs index a81dfdc7111..6d450127e5f 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs @@ -64,6 +64,7 @@ public partial class PackageManagerControl : UserControl, IVsWindowSearch, IDisp // This tells the operation execution part that it needs to trigger a refresh when done. private bool _isRefreshRequired; private bool _isExecutingAction; // Signifies where an action is being executed. Should be updated in a coordinated fashion with IsEnabled + private bool _isRestoreCompletedRefreshFallbackRequired = true; private RestartRequestBar _restartBar; private bool _missingPackageStatus; private bool _loadedAndInitialized = false; @@ -208,6 +209,7 @@ private async ValueTask InitializeAsync(PackageManagerModel model, INuGetUILogge solutionManager.ProjectUpdated += OnProjectUpdated; solutionManager.ProjectRenamed += OnProjectRenamed; solutionManager.AfterNuGetCacheUpdated += OnNuGetCacheUpdated; + solutionManager.SolutionRestoreCompleted += OnSolutionRestoreCompleted; Model.Context.ProjectActionsExecuted += OnProjectActionsExecuted; @@ -422,6 +424,39 @@ private void OnNuGetCacheUpdated(object sender, string e) } } + private void OnSolutionRestoreCompleted(object sender, bool restoreSucceeded) + { + var timeSpan = GetTimeSinceLastRefreshAndRestart(); + bool shouldRefresh = _isRestoreCompletedRefreshFallbackRequired; + _isRestoreCompletedRefreshFallbackRequired = true; + + if (!restoreSucceeded) + { + EmitRefreshEvent(timeSpan, RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NotApplicable); + return; + } + + // File saves that nominate projects already refresh the PM UI through the CacheUpdated path. + // In that case, restore-completed is only a fallback signal and should not trigger a second refresh. + if (!shouldRefresh) + { + EmitRefreshEvent(timeSpan, RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NoOp); + return; + } + + // Do not refresh if the UI is not visible. It will be refreshed later when the loaded event is called. + if (IsVisible) + { + NuGetUIThreadHelper.JoinableTaskFactory + .RunAsync(async () => await RefreshWhenNotExecutingActionAsync(RefreshOperationSource.RestoreCompleted, timeSpan)) + .PostOnFailure(nameof(PackageManagerControl), nameof(OnSolutionRestoreCompleted)); + } + else + { + EmitRefreshEvent(timeSpan, RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NoOp); + } + } + private async Task SolutionManager_CacheUpdatedAsync(TimeSpan timeSpan, string eventProjectFullName) { if (Model.IsSolution) @@ -1382,6 +1417,14 @@ private async Task RunAndEmitRefreshAsync(Func runner, RefreshOperationSou { await runner(); refreshStatus = RefreshOperationStatus.Success; + if (source == RefreshOperationSource.CacheUpdated) + { + _isRestoreCompletedRefreshFallbackRequired = false; + } + else if (source != RefreshOperationSource.RestoreCompleted) + { + _isRestoreCompletedRefreshFallbackRequired = true; + } } catch { @@ -1604,6 +1647,7 @@ private void CleanUp() solutionManager.ProjectUpdated -= OnProjectUpdated; solutionManager.ProjectRenamed -= OnProjectRenamed; solutionManager.AfterNuGetCacheUpdated -= OnNuGetCacheUpdated; + solutionManager.SolutionRestoreCompleted -= OnSolutionRestoreCompleted; Model.Context.ProjectActionsExecuted -= OnProjectActionsExecuted; diff --git a/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSolutionManagerService.cs b/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSolutionManagerService.cs index d8b7b6a3fa9..33bce689a09 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSolutionManagerService.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSolutionManagerService.cs @@ -9,6 +9,7 @@ using Microsoft.ServiceHub.Framework; using Microsoft.ServiceHub.Framework.Services; using Microsoft.VisualStudio.ComponentModelHost; +using NuGet.Common; using NuGet.VisualStudio; using NuGet.VisualStudio.Internal.Contracts; using NuGet.VisualStudio.Telemetry; @@ -24,12 +25,16 @@ public sealed class NuGetSolutionManagerService : INuGetSolutionManagerService [Import] private IVsSolutionManager? SolutionManager { get; set; } + [Import] + private IRestoreEvents? RestoreEvents { get; set; } + public event EventHandler? AfterNuGetCacheUpdated; public event EventHandler? AfterProjectRenamed; public event EventHandler? ProjectAdded; public event EventHandler? ProjectRemoved; public event EventHandler? ProjectRenamed; public event EventHandler? ProjectUpdated; + public event EventHandler? SolutionRestoreCompleted; private NuGetSolutionManagerService( ServiceActivationOptions options, @@ -47,6 +52,7 @@ private NuGetSolutionManagerService( componentModel.DefaultCompositionService.SatisfyImportsOnce(this); Assumes.NotNull(SolutionManager); + Assumes.NotNull(RestoreEvents); RegisterEventHandlers(); } @@ -90,6 +96,7 @@ public async ValueTask GetSolutionDirectoryAsync(CancellationToken cance private void RegisterEventHandlers() { Assumes.NotNull(SolutionManager); + Assumes.NotNull(RestoreEvents); SolutionManager!.AfterNuGetCacheUpdated += OnAfterNuGetCacheUpdated; SolutionManager!.AfterNuGetProjectRenamed += OnAfterProjectRenamed; @@ -97,6 +104,7 @@ private void RegisterEventHandlers() SolutionManager!.NuGetProjectRemoved += OnProjectRemoved; SolutionManager!.NuGetProjectRenamed += OnProjectRenamed; SolutionManager!.NuGetProjectUpdated += OnProjectUpdated; + RestoreEvents!.SolutionRestoreCompleted += OnSolutionRestoreCompleted; } private void OnAfterNuGetCacheUpdated(object sender, NuGetEventArgs e) @@ -129,6 +137,11 @@ private void OnProjectUpdated(object sender, NuGetProjectEventArgs e) OnProjectEvent(ProjectUpdated, nameof(OnProjectUpdated), sender, e); } + private void OnSolutionRestoreCompleted(SolutionRestoredEventArgs args) + { + SolutionRestoreCompleted?.Invoke(this, args.RestoreStatus == NuGetOperationStatus.Succeeded); + } + private void OnProjectEvent( EventHandler? eventHandler, string memberName, @@ -152,6 +165,7 @@ private void OnProjectEvent( private void UnregisterEventHandlers() { Assumes.NotNull(SolutionManager); + Assumes.NotNull(RestoreEvents); SolutionManager!.AfterNuGetCacheUpdated -= OnAfterNuGetCacheUpdated; SolutionManager!.AfterNuGetProjectRenamed -= OnAfterProjectRenamed; @@ -159,6 +173,7 @@ private void UnregisterEventHandlers() SolutionManager!.NuGetProjectRemoved -= OnProjectRemoved; SolutionManager!.NuGetProjectRenamed -= OnProjectRenamed; SolutionManager!.NuGetProjectUpdated -= OnProjectUpdated; + RestoreEvents!.SolutionRestoreCompleted -= OnSolutionRestoreCompleted; } } } diff --git a/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Telemetry/PackageManagerUIRefreshEvent.cs b/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Telemetry/PackageManagerUIRefreshEvent.cs index f053689e80d..36f8727cdfc 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Telemetry/PackageManagerUIRefreshEvent.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Telemetry/PackageManagerUIRefreshEvent.cs @@ -104,6 +104,7 @@ public enum RefreshOperationSource { ActionsExecuted, CacheUpdated, + RestoreCompleted, CheckboxPrereleaseChanged, ClearSearch, ExecuteAction, diff --git a/src/NuGet.Clients/NuGet.VisualStudio.Internal.Contracts/INuGetSolutionManagerService.cs b/src/NuGet.Clients/NuGet.VisualStudio.Internal.Contracts/INuGetSolutionManagerService.cs index 0f1502148b7..31f8671adf8 100644 --- a/src/NuGet.Clients/NuGet.VisualStudio.Internal.Contracts/INuGetSolutionManagerService.cs +++ b/src/NuGet.Clients/NuGet.VisualStudio.Internal.Contracts/INuGetSolutionManagerService.cs @@ -15,6 +15,7 @@ public interface INuGetSolutionManagerService : IDisposable event EventHandler ProjectRemoved; event EventHandler ProjectRenamed; event EventHandler ProjectUpdated; + event EventHandler SolutionRestoreCompleted; ValueTask GetSolutionDirectoryAsync(CancellationToken cancellationToken); } diff --git a/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/NuGetSolutionManagerServiceWrapperTests.cs b/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/NuGetSolutionManagerServiceWrapperTests.cs index f9910cbe601..9055e90e132 100644 --- a/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/NuGetSolutionManagerServiceWrapperTests.cs +++ b/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/NuGetSolutionManagerServiceWrapperTests.cs @@ -194,6 +194,29 @@ public void ProjectUpdated_Always_ForwardsEvent() } } + [Fact] + public void SolutionRestoreCompleted_Always_ForwardsEvent() + { + var service = new TestNuGetSolutionManagerService(); + + using (_wrapper.Swap(service)) + { + const bool expectedResult = true; + var eventRaised = false; + + _wrapper.SolutionRestoreCompleted += (sender, e) => + { + Assert.Equal(expectedResult, e); + + eventRaised = true; + }; + + service.RaiseSolutionRestoreCompleted(expectedResult); + + Assert.True(eventRaised); + } + } + [Fact] public async Task GetSolutionDirectoryAsync_Always_ReturnsSolutionDirectory() { @@ -219,6 +242,7 @@ private sealed class TestNuGetSolutionManagerService : INuGetSolutionManagerServ public event EventHandler ProjectRemoved; public event EventHandler ProjectRenamed; public event EventHandler ProjectUpdated; + public event EventHandler SolutionRestoreCompleted; internal string SolutionDirectory { get; set; } @@ -260,6 +284,11 @@ internal void RaiseProjectUpdated(IProjectContextInfo project) { ProjectUpdated?.Invoke(this, project); } + + internal void RaiseSolutionRestoreCompleted(bool restoreSucceeded) + { + SolutionRestoreCompleted?.Invoke(this, restoreSucceeded); + } } } } diff --git a/test/NuGet.Clients.Tests/NuGet.PackageManagement.VisualStudio.Test/Telemetry/NuGetTelemetryServiceTests.cs b/test/NuGet.Clients.Tests/NuGet.PackageManagement.VisualStudio.Test/Telemetry/NuGetTelemetryServiceTests.cs index f48c17163c0..b8092d17c08 100644 --- a/test/NuGet.Clients.Tests/NuGet.PackageManagement.VisualStudio.Test/Telemetry/NuGetTelemetryServiceTests.cs +++ b/test/NuGet.Clients.Tests/NuGet.PackageManagement.VisualStudio.Test/Telemetry/NuGetTelemetryServiceTests.cs @@ -91,6 +91,9 @@ public void NuGetTelemetryService_EmitProjectInformation(NuGetProjectType projec [InlineData(RefreshOperationSource.PackageSourcesChanged, RefreshOperationStatus.Success)] [InlineData(RefreshOperationSource.ProjectsChanged, RefreshOperationStatus.Success)] [InlineData(RefreshOperationSource.ProjectsChanged, RefreshOperationStatus.Failed)] + [InlineData(RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.Success)] + [InlineData(RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NoOp)] + [InlineData(RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NotApplicable)] [InlineData(RefreshOperationSource.RestartSearchCommand, RefreshOperationStatus.Success)] [InlineData(RefreshOperationSource.SourceSelectionChanged, RefreshOperationStatus.Success)] public void NuGetTelemetryService_EmitsPMUIRefreshEvent(RefreshOperationSource expectedRefreshSource, RefreshOperationStatus expectedRefreshStatus, bool expectedUiFiltering = false) From 0302c085da11b33a00a49bd7b0712bd058783954 Mon Sep 17 00:00:00 2001 From: Martin Ruiz Date: Mon, 23 Mar 2026 11:48:14 -0700 Subject: [PATCH 2/5] Change implementation --- .../NuGetSolutionManagerServiceWrapper.cs | 9 -- .../Xamls/PackageManagerControl.xaml.cs | 80 ++++++++++++------ .../Services/NuGetSolutionManagerService.cs | 15 ---- .../INuGetSolutionManagerService.cs | 1 - ...NuGetSolutionManagerServiceWrapperTests.cs | 29 ------- .../VsRestoreProgressEventsTests.cs | 82 +++++++++++++++++++ 6 files changed, 136 insertions(+), 80 deletions(-) diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/UserInterfaceService/NuGetSolutionManagerServiceWrapper.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/UserInterfaceService/NuGetSolutionManagerServiceWrapper.cs index 41c25417c36..bfa8f5960b8 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/UserInterfaceService/NuGetSolutionManagerServiceWrapper.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/UserInterfaceService/NuGetSolutionManagerServiceWrapper.cs @@ -19,7 +19,6 @@ internal sealed class NuGetSolutionManagerServiceWrapper : INuGetSolutionManager public event EventHandler? ProjectRemoved; public event EventHandler? ProjectRenamed; public event EventHandler? ProjectUpdated; - public event EventHandler? SolutionRestoreCompleted; internal INuGetSolutionManagerService Service { @@ -74,7 +73,6 @@ private void RegisterEventHandlers() _service.ProjectRemoved += OnProjectRemoved; _service.ProjectRenamed += OnProjectRenamed; _service.ProjectUpdated += OnProjectUpdated; - _service.SolutionRestoreCompleted += OnSolutionRestoreCompleted; } private void UnregisterEventHandlers() @@ -85,7 +83,6 @@ private void UnregisterEventHandlers() _service.ProjectRemoved -= OnProjectRemoved; _service.ProjectRenamed -= OnProjectRenamed; _service.ProjectUpdated -= OnProjectUpdated; - _service.SolutionRestoreCompleted -= OnSolutionRestoreCompleted; } private void OnAfterNuGetCacheUpdated(object sender, string e) @@ -118,11 +115,6 @@ private void OnProjectUpdated(object sender, IProjectContextInfo e) ProjectUpdated?.Invoke(this, e); } - private void OnSolutionRestoreCompleted(object sender, bool e) - { - SolutionRestoreCompleted?.Invoke(this, e); - } - private sealed class NullNuGetSolutionManagerService : INuGetSolutionManagerService { public event EventHandler AfterNuGetCacheUpdated { add { } remove { } } @@ -131,7 +123,6 @@ public event EventHandler ProjectAdded { add { } remove { } public event EventHandler ProjectRemoved { add { } remove { } } public event EventHandler ProjectRenamed { add { } remove { } } public event EventHandler ProjectUpdated { add { } remove { } } - public event EventHandler SolutionRestoreCompleted { add { } remove { } } internal static NullNuGetSolutionManagerService Instance { get; } = new NullNuGetSolutionManagerService(); diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs index 6d450127e5f..e0a533cc7f1 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs @@ -64,7 +64,7 @@ public partial class PackageManagerControl : UserControl, IVsWindowSearch, IDisp // This tells the operation execution part that it needs to trigger a refresh when done. private bool _isRefreshRequired; private bool _isExecutingAction; // Signifies where an action is being executed. Should be updated in a coordinated fashion with IsEnabled - private bool _isRestoreCompletedRefreshFallbackRequired = true; + private IVsNuGetProjectUpdateEvents _projectUpdateEvents; private RestartRequestBar _restartBar; private bool _missingPackageStatus; private bool _loadedAndInitialized = false; @@ -209,7 +209,10 @@ private async ValueTask InitializeAsync(PackageManagerModel model, INuGetUILogge solutionManager.ProjectUpdated += OnProjectUpdated; solutionManager.ProjectRenamed += OnProjectRenamed; solutionManager.AfterNuGetCacheUpdated += OnNuGetCacheUpdated; - solutionManager.SolutionRestoreCompleted += OnSolutionRestoreCompleted; + + _projectUpdateEvents = await ServiceLocator.GetComponentModelServiceAsync(); + _projectUpdateEvents.ProjectUpdateFinished += OnProjectUpdateFinished; + _projectUpdateEvents.SolutionRestoreFinished += OnSolutionRestoreFinished; Model.Context.ProjectActionsExecuted += OnProjectActionsExecuted; @@ -412,7 +415,7 @@ private void OnNuGetCacheUpdated(object sender, string e) { var timeSpan = GetTimeSinceLastRefreshAndRestart(); // Do not refresh if the UI is not visible. It will be refreshed later when the loaded event is called. - if (IsVisible) + if (IsLoaded) { NuGetUIThreadHelper.JoinableTaskFactory .RunAsync(() => SolutionManager_CacheUpdatedAsync(timeSpan, e)) @@ -424,37 +427,65 @@ private void OnNuGetCacheUpdated(object sender, string e) } } - private void OnSolutionRestoreCompleted(object sender, bool restoreSucceeded) + private void OnProjectUpdateFinished(string projectUniqueName, IReadOnlyList updatedFiles) { var timeSpan = GetTimeSinceLastRefreshAndRestart(); - bool shouldRefresh = _isRestoreCompletedRefreshFallbackRequired; - _isRestoreCompletedRefreshFallbackRequired = true; - if (!restoreSucceeded) + if (!IsVisible) { - EmitRefreshEvent(timeSpan, RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NotApplicable); + EmitRefreshEvent(timeSpan, RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NoOp); return; } - // File saves that nominate projects already refresh the PM UI through the CacheUpdated path. - // In that case, restore-completed is only a fallback signal and should not trigger a second refresh. - if (!shouldRefresh) + if (Model.IsSolution) { - EmitRefreshEvent(timeSpan, RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NoOp); + // Solution-level PM UI: skip per-project signals; wait for SolutionRestoreFinished instead + // to avoid N refreshes for N projects. return; } - // Do not refresh if the UI is not visible. It will be refreshed later when the loaded event is called. - if (IsVisible) + // Project-level PM UI: only refresh when the updated project matches the viewed project. + NuGetUIThreadHelper.JoinableTaskFactory + .RunAsync(() => ProjectUpdateFinishedAsync(timeSpan, projectUniqueName)) + .PostOnFailure(nameof(PackageManagerControl), nameof(OnProjectUpdateFinished)); + } + + private async Task ProjectUpdateFinishedAsync(TimeSpan timeSpan, string projectUniqueName) + { + IProjectContextInfo project = Model.Context.Projects.First(); + IProjectMetadataContextInfo projectMetadata = await project.GetMetadataAsync( + Model.Context.ServiceBroker, + CancellationToken.None); + + if (string.Equals(projectMetadata.FullPath, projectUniqueName, StringComparison.OrdinalIgnoreCase)) { - NuGetUIThreadHelper.JoinableTaskFactory - .RunAsync(async () => await RefreshWhenNotExecutingActionAsync(RefreshOperationSource.RestoreCompleted, timeSpan)) - .PostOnFailure(nameof(PackageManagerControl), nameof(OnSolutionRestoreCompleted)); + await RefreshWhenNotExecutingActionAsync(RefreshOperationSource.RestoreCompleted, timeSpan); } else + { + EmitRefreshEvent(timeSpan, RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NotApplicable); + } + } + + private void OnSolutionRestoreFinished(IReadOnlyList projects) + { + var timeSpan = GetTimeSinceLastRefreshAndRestart(); + + if (!Model.IsSolution) + { + // Project-level PM UI handles refresh via OnProjectUpdateFinished. + return; + } + + if (!IsVisible) { EmitRefreshEvent(timeSpan, RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NoOp); + return; } + + NuGetUIThreadHelper.JoinableTaskFactory + .RunAsync(async () => await RefreshWhenNotExecutingActionAsync(RefreshOperationSource.RestoreCompleted, timeSpan)) + .PostOnFailure(nameof(PackageManagerControl), nameof(OnSolutionRestoreFinished)); } private async Task SolutionManager_CacheUpdatedAsync(TimeSpan timeSpan, string eventProjectFullName) @@ -1417,14 +1448,6 @@ private async Task RunAndEmitRefreshAsync(Func runner, RefreshOperationSou { await runner(); refreshStatus = RefreshOperationStatus.Success; - if (source == RefreshOperationSource.CacheUpdated) - { - _isRestoreCompletedRefreshFallbackRequired = false; - } - else if (source != RefreshOperationSource.RestoreCompleted) - { - _isRestoreCompletedRefreshFallbackRequired = true; - } } catch { @@ -1647,7 +1670,12 @@ private void CleanUp() solutionManager.ProjectUpdated -= OnProjectUpdated; solutionManager.ProjectRenamed -= OnProjectRenamed; solutionManager.AfterNuGetCacheUpdated -= OnNuGetCacheUpdated; - solutionManager.SolutionRestoreCompleted -= OnSolutionRestoreCompleted; + + if (_projectUpdateEvents != null) + { + _projectUpdateEvents.ProjectUpdateFinished -= OnProjectUpdateFinished; + _projectUpdateEvents.SolutionRestoreFinished -= OnSolutionRestoreFinished; + } Model.Context.ProjectActionsExecuted -= OnProjectActionsExecuted; diff --git a/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSolutionManagerService.cs b/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSolutionManagerService.cs index 33bce689a09..d8b7b6a3fa9 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSolutionManagerService.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSolutionManagerService.cs @@ -9,7 +9,6 @@ using Microsoft.ServiceHub.Framework; using Microsoft.ServiceHub.Framework.Services; using Microsoft.VisualStudio.ComponentModelHost; -using NuGet.Common; using NuGet.VisualStudio; using NuGet.VisualStudio.Internal.Contracts; using NuGet.VisualStudio.Telemetry; @@ -25,16 +24,12 @@ public sealed class NuGetSolutionManagerService : INuGetSolutionManagerService [Import] private IVsSolutionManager? SolutionManager { get; set; } - [Import] - private IRestoreEvents? RestoreEvents { get; set; } - public event EventHandler? AfterNuGetCacheUpdated; public event EventHandler? AfterProjectRenamed; public event EventHandler? ProjectAdded; public event EventHandler? ProjectRemoved; public event EventHandler? ProjectRenamed; public event EventHandler? ProjectUpdated; - public event EventHandler? SolutionRestoreCompleted; private NuGetSolutionManagerService( ServiceActivationOptions options, @@ -52,7 +47,6 @@ private NuGetSolutionManagerService( componentModel.DefaultCompositionService.SatisfyImportsOnce(this); Assumes.NotNull(SolutionManager); - Assumes.NotNull(RestoreEvents); RegisterEventHandlers(); } @@ -96,7 +90,6 @@ public async ValueTask GetSolutionDirectoryAsync(CancellationToken cance private void RegisterEventHandlers() { Assumes.NotNull(SolutionManager); - Assumes.NotNull(RestoreEvents); SolutionManager!.AfterNuGetCacheUpdated += OnAfterNuGetCacheUpdated; SolutionManager!.AfterNuGetProjectRenamed += OnAfterProjectRenamed; @@ -104,7 +97,6 @@ private void RegisterEventHandlers() SolutionManager!.NuGetProjectRemoved += OnProjectRemoved; SolutionManager!.NuGetProjectRenamed += OnProjectRenamed; SolutionManager!.NuGetProjectUpdated += OnProjectUpdated; - RestoreEvents!.SolutionRestoreCompleted += OnSolutionRestoreCompleted; } private void OnAfterNuGetCacheUpdated(object sender, NuGetEventArgs e) @@ -137,11 +129,6 @@ private void OnProjectUpdated(object sender, NuGetProjectEventArgs e) OnProjectEvent(ProjectUpdated, nameof(OnProjectUpdated), sender, e); } - private void OnSolutionRestoreCompleted(SolutionRestoredEventArgs args) - { - SolutionRestoreCompleted?.Invoke(this, args.RestoreStatus == NuGetOperationStatus.Succeeded); - } - private void OnProjectEvent( EventHandler? eventHandler, string memberName, @@ -165,7 +152,6 @@ private void OnProjectEvent( private void UnregisterEventHandlers() { Assumes.NotNull(SolutionManager); - Assumes.NotNull(RestoreEvents); SolutionManager!.AfterNuGetCacheUpdated -= OnAfterNuGetCacheUpdated; SolutionManager!.AfterNuGetProjectRenamed -= OnAfterProjectRenamed; @@ -173,7 +159,6 @@ private void UnregisterEventHandlers() SolutionManager!.NuGetProjectRemoved -= OnProjectRemoved; SolutionManager!.NuGetProjectRenamed -= OnProjectRenamed; SolutionManager!.NuGetProjectUpdated -= OnProjectUpdated; - RestoreEvents!.SolutionRestoreCompleted -= OnSolutionRestoreCompleted; } } } diff --git a/src/NuGet.Clients/NuGet.VisualStudio.Internal.Contracts/INuGetSolutionManagerService.cs b/src/NuGet.Clients/NuGet.VisualStudio.Internal.Contracts/INuGetSolutionManagerService.cs index 31f8671adf8..0f1502148b7 100644 --- a/src/NuGet.Clients/NuGet.VisualStudio.Internal.Contracts/INuGetSolutionManagerService.cs +++ b/src/NuGet.Clients/NuGet.VisualStudio.Internal.Contracts/INuGetSolutionManagerService.cs @@ -15,7 +15,6 @@ public interface INuGetSolutionManagerService : IDisposable event EventHandler ProjectRemoved; event EventHandler ProjectRenamed; event EventHandler ProjectUpdated; - event EventHandler SolutionRestoreCompleted; ValueTask GetSolutionDirectoryAsync(CancellationToken cancellationToken); } diff --git a/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/NuGetSolutionManagerServiceWrapperTests.cs b/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/NuGetSolutionManagerServiceWrapperTests.cs index 9055e90e132..f9910cbe601 100644 --- a/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/NuGetSolutionManagerServiceWrapperTests.cs +++ b/test/NuGet.Clients.Tests/NuGet.PackageManagement.UI.Test/NuGetSolutionManagerServiceWrapperTests.cs @@ -194,29 +194,6 @@ public void ProjectUpdated_Always_ForwardsEvent() } } - [Fact] - public void SolutionRestoreCompleted_Always_ForwardsEvent() - { - var service = new TestNuGetSolutionManagerService(); - - using (_wrapper.Swap(service)) - { - const bool expectedResult = true; - var eventRaised = false; - - _wrapper.SolutionRestoreCompleted += (sender, e) => - { - Assert.Equal(expectedResult, e); - - eventRaised = true; - }; - - service.RaiseSolutionRestoreCompleted(expectedResult); - - Assert.True(eventRaised); - } - } - [Fact] public async Task GetSolutionDirectoryAsync_Always_ReturnsSolutionDirectory() { @@ -242,7 +219,6 @@ private sealed class TestNuGetSolutionManagerService : INuGetSolutionManagerServ public event EventHandler ProjectRemoved; public event EventHandler ProjectRenamed; public event EventHandler ProjectUpdated; - public event EventHandler SolutionRestoreCompleted; internal string SolutionDirectory { get; set; } @@ -284,11 +260,6 @@ internal void RaiseProjectUpdated(IProjectContextInfo project) { ProjectUpdated?.Invoke(this, project); } - - internal void RaiseSolutionRestoreCompleted(bool restoreSucceeded) - { - SolutionRestoreCompleted?.Invoke(this, restoreSucceeded); - } } } } diff --git a/test/NuGet.Clients.Tests/NuGet.SolutionRestoreManager.Test/VsRestoreProgressEventsTests.cs b/test/NuGet.Clients.Tests/NuGet.SolutionRestoreManager.Test/VsRestoreProgressEventsTests.cs index 461015add94..5c08c4e1a61 100644 --- a/test/NuGet.Clients.Tests/NuGet.SolutionRestoreManager.Test/VsRestoreProgressEventsTests.cs +++ b/test/NuGet.Clients.Tests/NuGet.SolutionRestoreManager.Test/VsRestoreProgressEventsTests.cs @@ -246,5 +246,87 @@ public void EndProjectUpdate_WhenBatchEventEndIsRaisedWithNonProject_DoesNotFire Assert.Equal(0, invocations); } + + [Fact] + public void EndProjectUpdate_WhenSubscriberThrows_OtherSubscribersStillReceiveEvent() + { + var restoreProgressEvents = new VsRestoreProgressEvents(_packageProjectProvider.Object, new Mock().Object); + + var expectedProjectName = "projectName.csproj"; + var expectedFileList = new List() { "project.assets.json" }; + bool secondHandlerCalled = false; + + restoreProgressEvents.ProjectUpdateFinished += (projectUniqueName, updatedFiles) => + { + throw new InvalidOperationException("Simulated handler failure"); + }; + + restoreProgressEvents.ProjectUpdateFinished += (projectUniqueName, updatedFiles) => + { + secondHandlerCalled = true; + }; + + restoreProgressEvents.EndProjectUpdate(expectedProjectName, expectedFileList); + + Assert.True(secondHandlerCalled); + } + + [Fact] + public void EndSolutionRestore_WhenSubscriberThrows_OtherSubscribersStillReceiveEvent() + { + var restoreProgressEvents = new VsRestoreProgressEvents(_packageProjectProvider.Object, new Mock().Object); + + var expectedProjectList = new List() { "projectName.csproj" }; + bool secondHandlerCalled = false; + + restoreProgressEvents.SolutionRestoreFinished += (projects) => + { + throw new InvalidOperationException("Simulated handler failure"); + }; + + restoreProgressEvents.SolutionRestoreFinished += (projects) => + { + secondHandlerCalled = true; + }; + + restoreProgressEvents.EndSolutionRestore(expectedProjectList); + + Assert.True(secondHandlerCalled); + } + + [Fact] + public void EndProjectUpdate_WithMultipleSubscribers_AllReceiveEvent() + { + var restoreProgressEvents = new VsRestoreProgressEvents(_packageProjectProvider.Object, new Mock().Object); + + var expectedProjectName = "projectName.csproj"; + var expectedFileList = new List() { "project.assets.json" }; + int handlerCallCount = 0; + + restoreProgressEvents.ProjectUpdateFinished += (projectUniqueName, updatedFiles) => handlerCallCount++; + restoreProgressEvents.ProjectUpdateFinished += (projectUniqueName, updatedFiles) => handlerCallCount++; + restoreProgressEvents.ProjectUpdateFinished += (projectUniqueName, updatedFiles) => handlerCallCount++; + + restoreProgressEvents.EndProjectUpdate(expectedProjectName, expectedFileList); + + Assert.Equal(3, handlerCallCount); + } + + [Fact] + public void EndSolutionRestore_WithMultipleSubscribers_AllReceiveEvent() + { + var restoreProgressEvents = new VsRestoreProgressEvents(_packageProjectProvider.Object, new Mock().Object); + + var expectedProjectList = new List() { "projectA.csproj", "projectB.csproj" }; + int handlerCallCount = 0; + + restoreProgressEvents.SolutionRestoreFinished += (projects) => handlerCallCount++; + restoreProgressEvents.SolutionRestoreFinished += (projects) => handlerCallCount++; + restoreProgressEvents.SolutionRestoreFinished += (projects) => handlerCallCount++; + + restoreProgressEvents.EndSolutionRestore(expectedProjectList); + + Assert.Equal(3, handlerCallCount); + } } } From 2f1699ccc5ea3f235f31d2678ff916208ef70d75 Mon Sep 17 00:00:00 2001 From: Martin Ruiz Date: Mon, 23 Mar 2026 13:47:03 -0700 Subject: [PATCH 3/5] Remove refreshing when cacheupdated --- .../Xamls/PackageManagerControl.xaml.cs | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs index e0a533cc7f1..7703839f4b4 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs @@ -208,8 +208,6 @@ private async ValueTask InitializeAsync(PackageManagerModel model, INuGetUILogge solutionManager.ProjectRemoved += OnProjectChanged; solutionManager.ProjectUpdated += OnProjectUpdated; solutionManager.ProjectRenamed += OnProjectRenamed; - solutionManager.AfterNuGetCacheUpdated += OnNuGetCacheUpdated; - _projectUpdateEvents = await ServiceLocator.GetComponentModelServiceAsync(); _projectUpdateEvents.ProjectUpdateFinished += OnProjectUpdateFinished; _projectUpdateEvents.SolutionRestoreFinished += OnSolutionRestoreFinished; @@ -411,22 +409,6 @@ private async ValueTask RefreshProjectAfterActionAsync(TimeSpan timeSpan, IReadO } } - private void OnNuGetCacheUpdated(object sender, string e) - { - var timeSpan = GetTimeSinceLastRefreshAndRestart(); - // Do not refresh if the UI is not visible. It will be refreshed later when the loaded event is called. - if (IsLoaded) - { - NuGetUIThreadHelper.JoinableTaskFactory - .RunAsync(() => SolutionManager_CacheUpdatedAsync(timeSpan, e)) - .PostOnFailure(nameof(PackageManagerControl), nameof(OnNuGetCacheUpdated)); - } - else - { - EmitRefreshEvent(timeSpan, RefreshOperationSource.CacheUpdated, RefreshOperationStatus.NoOp); - } - } - private void OnProjectUpdateFinished(string projectUniqueName, IReadOnlyList updatedFiles) { var timeSpan = GetTimeSinceLastRefreshAndRestart(); @@ -488,33 +470,6 @@ private void OnSolutionRestoreFinished(IReadOnlyList projects) .PostOnFailure(nameof(PackageManagerControl), nameof(OnSolutionRestoreFinished)); } - private async Task SolutionManager_CacheUpdatedAsync(TimeSpan timeSpan, string eventProjectFullName) - { - if (Model.IsSolution) - { - await RefreshWhenNotExecutingActionAsync(RefreshOperationSource.CacheUpdated, timeSpan); - } - else - { - // This is a project package manager, so there is one and only one project. - IProjectContextInfo project = Model.Context.Projects.First(); - IProjectMetadataContextInfo projectMetadata = await project.GetMetadataAsync( - Model.Context.ServiceBroker, - CancellationToken.None); - - // This ensures that we refresh the UI only if the event.project.FullName matches the NuGetProject.FullName. - // We also refresh the UI if projectFullPath is not present. - if (projectMetadata.FullPath == eventProjectFullName) - { - await RefreshWhenNotExecutingActionAsync(RefreshOperationSource.CacheUpdated, timeSpan); - } - else - { - EmitRefreshEvent(timeSpan, RefreshOperationSource.CacheUpdated, RefreshOperationStatus.NotApplicable); - } - } - } - private async ValueTask RefreshWhenNotExecutingActionAsync(RefreshOperationSource source, TimeSpan timeSpanSinceLastRefresh) { // Only refresh if there is no executing action. Tell the operation execution to refresh when done otherwise. @@ -1669,7 +1624,6 @@ private void CleanUp() solutionManager.ProjectRemoved -= OnProjectChanged; solutionManager.ProjectUpdated -= OnProjectUpdated; solutionManager.ProjectRenamed -= OnProjectRenamed; - solutionManager.AfterNuGetCacheUpdated -= OnNuGetCacheUpdated; if (_projectUpdateEvents != null) { From 400621e4d84b696af9b914116a746c67f271905c Mon Sep 17 00:00:00 2001 From: Martin Ruiz Date: Mon, 23 Mar 2026 14:14:36 -0700 Subject: [PATCH 4/5] Refresh PM UI when restore finishes --- .../Xamls/PackageManagerControl.xaml.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs index 7703839f4b4..63e1f12ebee 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs @@ -415,6 +415,7 @@ private void OnProjectUpdateFinished(string projectUniqueName, IReadOnlyList projects) if (!IsVisible) { + _isRefreshRequired = true; EmitRefreshEvent(timeSpan, RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NoOp); return; } @@ -563,6 +565,11 @@ await RunAndEmitRefreshAsync(async () => }, RefreshOperationSource.PackageManagerLoaded, timeSpan, sw); } + else if (_isRefreshRequired) + { + _isRefreshRequired = false; + await RunAndEmitRefreshAsync(async () => await RefreshAsync(), RefreshOperationSource.PackageManagerLoaded, timeSpan, sw); + } else { EmitRefreshEvent(timeSpan, RefreshOperationSource.PackageManagerLoaded, RefreshOperationStatus.NoOp, isUIFiltering: false, 0); From 2b6ba075113e996aec515926c9b4682bb5052b44 Mon Sep 17 00:00:00 2001 From: Martin Ruiz Date: Mon, 23 Mar 2026 14:38:36 -0700 Subject: [PATCH 5/5] Skip PM UI refresh on no-op solution restores Use _projectUpdateOccurredDuringRestore flag to track whether any project had a non-no-op restore. Solution-level PM UI only refreshes in OnSolutionRestoreFinished if the flag is set, avoiding unnecessary refreshes when the entire restore was a no-op. Also reorder OnProjectUpdateFinished to check Model.IsSolution first, setting the flag for solution-level PM UI before checking visibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Xamls/PackageManagerControl.xaml.cs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs index 63e1f12ebee..7e29dec9842 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.UI/Xamls/PackageManagerControl.xaml.cs @@ -64,6 +64,7 @@ public partial class PackageManagerControl : UserControl, IVsWindowSearch, IDisp // This tells the operation execution part that it needs to trigger a refresh when done. private bool _isRefreshRequired; private bool _isExecutingAction; // Signifies where an action is being executed. Should be updated in a coordinated fashion with IsEnabled + private bool _projectUpdateOccurredDuringRestore; private IVsNuGetProjectUpdateEvents _projectUpdateEvents; private RestartRequestBar _restartBar; private bool _missingPackageStatus; @@ -413,17 +414,18 @@ private void OnProjectUpdateFinished(string projectUniqueName, IReadOnlyList projects) return; } + // Only refresh if at least one project had a non-no-op restore. + if (!_projectUpdateOccurredDuringRestore) + { + EmitRefreshEvent(timeSpan, RefreshOperationSource.RestoreCompleted, RefreshOperationStatus.NoOp); + return; + } + + _projectUpdateOccurredDuringRestore = false; + if (!IsVisible) { _isRefreshRequired = true;