From 001b8e9645ec40a36c43cb8a8bf0bbd885f1ce06 Mon Sep 17 00:00:00 2001 From: Cyrus Najmabadi Date: Thu, 29 May 2025 15:35:57 +0200 Subject: [PATCH 1/8] Extract out a service --- .../AbstractGoOrFindCommandHandler.cs | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs b/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs index 8abee6c88de4e..b4fa92d69ce86 100644 --- a/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs +++ b/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs @@ -26,6 +26,272 @@ namespace Microsoft.CodeAnalysis.GoToDefinition; +internal abstract class AbstractGoOrFindService( + IThreadingContext threadingContext, + IStreamingFindUsagesPresenter streamingPresenter, + IAsynchronousOperationListener listener, + IGlobalOptionService globalOptions) + where TLanguageService : class, ILanguageService +{ + private readonly IThreadingContext _threadingContext = threadingContext; + private readonly IStreamingFindUsagesPresenter _streamingPresenter = streamingPresenter; + private readonly IAsynchronousOperationListener _listener = listener; + + public readonly OptionsProvider ClassificationOptionsProvider = globalOptions.GetClassificationOptionsProvider(); + + /// + /// The current go-to command that is in progress. Tracked so that if we issue multiple find-impl commands that + /// they properly run after each other. This is necessary so none of them accidentally stomp on one that is still + /// in progress and is interacting with the UI. Only valid to read or write to this on the UI thread. + /// + private Task _inProgressCommand = Task.CompletedTask; + + /// + /// CancellationToken governing the current . Only valid to read or write to this + /// on the UI thread. + /// + /// + /// Cancellation is complicated with this feature. There are two things that can cause us to cancel. The first is + /// if the user kicks off another actual go-to-impl command. In that case, we just attempt to cancel the prior + /// command (if it is still running), then wait for it to complete, then run our command. The second is if we have + /// switched over to the streaming presenter and then the user starts some other command (like FAR) that takes over + /// the presenter. In that case, the presenter will notify us that it has be re-purposed and we will also cancel + /// this source. + /// + private readonly CancellationSeries _cancellationSeries = new(threadingContext.DisposalToken); + + /// + /// This hook allows for stabilizing the asynchronous nature of this command handler for integration testing. + /// + private Func? _delayHook; + + public abstract string DisplayName { get; } + + protected abstract FunctionId FunctionId { get; } + + /// + /// If we should try to navigate to the sole item found, if that item was found within 1.5seconds. + /// + protected abstract bool NavigateToSingleResultIfQuick { get; } + + protected virtual StreamingFindUsagesPresenterOptions GetStreamingPresenterOptions(Document document) + => StreamingFindUsagesPresenterOptions.Default; + + protected abstract Task FindActionAsync(IFindUsagesContext context, Document document, TLanguageService service, int caretPosition, CancellationToken cancellationToken); + + private static (Document?, TLanguageService?) GetDocumentAndService(ITextSnapshot snapshot) + { + var document = snapshot.GetOpenDocumentInCurrentContextWithChanges(); + return (document, document?.GetLanguageService()); + } + + public bool IsAvailable(Document document) + { + return document?.GetLanguageService() != null; + } + + public bool ExecuteCommand(Document document, int position) + { + _threadingContext.ThrowIfNotOnUIThread(); + if (document is null) + return false; + + var service = document?.GetLanguageService(); + if (service == null) + return false; + + // cancel any prior find-refs that might be in progress. + var cancellationToken = _cancellationSeries.CreateNext(); + + // we're going to return immediately from ExecuteCommand and kick off our own async work to invoke the + // operation. Once this returns, the editor will close the threaded wait dialog it created. + _inProgressCommand = ExecuteCommandAsync(document, service, position, cancellationToken); + return true; + } + + private async Task ExecuteCommandAsync( + Document document, + TLanguageService service, + int position, + CancellationToken cancellationToken) + { + // This is a fire-and-forget method (nothing guarantees observing it). As such, we have to handle cancellation + // and failure ourselves. + try + { + _threadingContext.ThrowIfNotOnUIThread(); + + // Make an tracking token so that integration tests can wait until we're complete. + using var token = _listener.BeginAsyncOperation($"{GetType().Name}.{nameof(ExecuteCommandAsync)}"); + + // Only start running once the previous command has finished. That way we don't have results from both + // potentially interleaving with each other. Note: this should ideally always be fast as long as the prior + // task respects cancellation. + // + // Note: we just need to make sure we run after that prior command finishes. We do not want to propagate + // any failures from it. Technically this should not be possible as it should be inside this same + // try/catch. however this code wants to be very resilient to any prior mistakes infecting later operations. + await _inProgressCommand.NoThrowAwaitable(captureContext: false); + await ExecuteCommandWorkerAsync(document, service, position, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) when (FatalError.ReportAndCatch(ex)) + { + } + } + + private async Task ExecuteCommandWorkerAsync( + Document document, + TLanguageService service, + int position, + CancellationToken cancellationToken) + { + // Switch to the BG immediately so we can keep as much work off the UI thread. + await TaskScheduler.Default; + + // We kick off the work to find the impl/base in the background. If we get the results for it within 1.5 + // seconds, we then either navigate directly to it (in the case of one result), or we show all the results in + // the presenter (in the case of multiple). + // + // However, if the results don't come back in 1.5 seconds, we just pop open the presenter and continue the + // search there. That way the user is not blocked and can go do other work if they want. + + // We create our own context object, simply to capture all the definitions reported by the individual + // TLanguageService. Once we get the results back we'll then decide what to do with them. If we get only a + // single result back, then we'll just go directly to it. Otherwise, we'll present the results in the + // IStreamingFindUsagesPresenter. + var findContext = new BufferedFindUsagesContext(); + + var delayBeforeShowingResultsWindowTask = DelayAsync(cancellationToken); + var findTask = FindResultsAsync(findContext, document, service, position, cancellationToken); + + var firstFinishedTask = await Task.WhenAny(delayBeforeShowingResultsWindowTask, findTask).ConfigureAwait(false); + if (cancellationToken.IsCancellationRequested) + // we bailed out because another command was issued. Immediately stop everything we're doing and return + // back so the next operation can run. + return; + + if (this.NavigateToSingleResultIfQuick && firstFinishedTask == findTask) + { + // We completed the search within 1.5 seconds. If we had at least one result then Navigate to it directly + // (if there is just one) or present them all if there are many. + var definitions = await findContext.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false); + if (definitions.Length > 0) + { + var title = await findContext.GetSearchTitleAsync(cancellationToken).ConfigureAwait(false); + await _streamingPresenter.TryPresentLocationOrNavigateIfOneAsync( + _threadingContext, + document.Project.Solution.Workspace, + title ?? DisplayName, + definitions, + cancellationToken).ConfigureAwait(false); + return; + } + } + + // We either got no results, or 1.5 has passed and we didn't figure out the symbols to navigate to or + // present. So pop up the presenter to show the user that we're involved in a longer search, without + // blocking them. + await PresentResultsInStreamingPresenterAsync(document, findContext, findTask, cancellationToken).ConfigureAwait(false); + } + + private Task DelayAsync(CancellationToken cancellationToken) + { + if (_delayHook is { } delayHook) + { + return delayHook(cancellationToken); + } + + // If we want to navigate to a single result if it is found quickly, then delay showing the find-refs winfor + // for 1.5 seconds to see if a result comes in by then. If we're not navigating and are always showing the + // far window, then don't have any delay showing the window. + var delay = this.NavigateToSingleResultIfQuick + ? DelayTimeSpan.Idle + : TimeSpan.Zero; + + return Task.Delay(delay, cancellationToken); + } + + private async Task PresentResultsInStreamingPresenterAsync( + Document document, + BufferedFindUsagesContext findContext, + Task findTask, + CancellationToken cancellationToken) + { + await _threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + var (presenterContext, presenterCancellationToken) = _streamingPresenter.StartSearch(DisplayName, GetStreamingPresenterOptions(document)); + + try + { + await TaskScheduler.Default; + + // Now, tell our find-context (which has been collecting intermediary results) to swap over to using the + // actual presenter context. It will push all results it's been collecting into that, and from that + // point onwards will just forward any new results directly to the presenter. + await findContext.AttachToStreamingPresenterAsync(presenterContext, cancellationToken).ConfigureAwait(false); + + // Hook up the presenter's cancellation token to our overall governing cancellation token. In other + // words, if something else decides to present in the presenter (like a find-refs call) we'll hear about + // that and can cancel all our work. + presenterCancellationToken.Register(() => _cancellationSeries.CreateNext()); + + // now actually wait for the find work to be done. + await findTask.ConfigureAwait(false); + } + finally + { + // Ensure that once we pop up the presenter, we always make sure to force it to the completed stage in + // case some other find operation happens (either through this handler or another handler using the + // presenter) and we don't actually finish the search. + await presenterContext.OnCompletedAsync(cancellationToken).ConfigureAwait(false); + } + } + + private async Task FindResultsAsync( + IFindUsagesContext findContext, Document document, TLanguageService service, int position, CancellationToken cancellationToken) + { + // Ensure that we relinquish the thread so that the caller can proceed with their work. + await TaskScheduler.Default.SwitchTo(alwaysYield: true); + + using (Logger.LogBlock(FunctionId, KeyValueLogMessage.Create(LogType.UserAction), cancellationToken)) + { + await findContext.SetSearchTitleAsync(DisplayName, cancellationToken).ConfigureAwait(false); + + // Let the user know in the FAR window if results may be inaccurate because this is running prior to the + // solution being fully loaded. + var statusService = document.Project.Solution.Services.GetRequiredService(); + var isFullyLoaded = await statusService.IsFullyLoadedAsync(cancellationToken).ConfigureAwait(false); + if (!isFullyLoaded) + { + await findContext.ReportMessageAsync( + EditorFeaturesResources.The_results_may_be_incomplete_due_to_the_solution_still_loading_projects, NotificationSeverity.Information, cancellationToken).ConfigureAwait(false); + } + + // We were able to find the doc prior to loading the workspace (or else we would not have the service). + // So we better be able to find it afterwards. + await FindActionAsync(findContext, document, service, position, cancellationToken).ConfigureAwait(false); + } + } + + internal TestAccessor GetTestAccessor() + { + return new TestAccessor(this); + } + + internal readonly struct TestAccessor + { + private readonly AbstractGoOrFindService _instance; + + internal TestAccessor(AbstractGoOrFindService instance) + => _instance = instance; + + internal ref Func? DelayHook + => ref _instance._delayHook; + } +} + internal abstract class AbstractGoOrFindCommandHandler( IThreadingContext threadingContext, IStreamingFindUsagesPresenter streamingPresenter, From a2db5aa2873a4bb8830504bc1bd9b5238063b780 Mon Sep 17 00:00:00 2001 From: Cyrus Najmabadi Date: Thu, 29 May 2025 15:48:38 +0200 Subject: [PATCH 2/8] Make service --- .../Core/GoToBase/GoToBaseCommandHandler.cs | 22 +- .../AbstractGoOrFindCommandHandler.cs | 538 +----------------- .../AbstractGoOrFindNavigationService.cs | 284 +++++++++ 3 files changed, 313 insertions(+), 531 deletions(-) create mode 100644 src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindNavigationService.cs diff --git a/src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs b/src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs index bca07973c714e..8092110ab9ae0 100644 --- a/src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs +++ b/src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs @@ -21,16 +21,12 @@ namespace Microsoft.CodeAnalysis.GoToBase; -[Export(typeof(VSCommanding.ICommandHandler))] -[ContentType(ContentTypeNames.RoslynContentType)] -[Name(PredefinedCommandHandlerNames.GoToBase)] -[method: ImportingConstructor] -[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] -internal sealed class GoToBaseCommandHandler( +internal sealed class GoToBaseNavigationService( IThreadingContext threadingContext, IStreamingFindUsagesPresenter streamingPresenter, IAsynchronousOperationListenerProvider listenerProvider, - IGlobalOptionService globalOptions) : AbstractGoOrFindCommandHandler( + IGlobalOptionService globalOptions) + : AbstractGoOrFindNavigationService( threadingContext, streamingPresenter, listenerProvider.GetListener(FeatureAttribute.GoToBase), @@ -49,3 +45,15 @@ internal sealed class GoToBaseCommandHandler( protected override Task FindActionAsync(IFindUsagesContext context, Document document, IGoToBaseService service, int caretPosition, CancellationToken cancellationToken) => service.FindBasesAsync(context, document, caretPosition, ClassificationOptionsProvider, cancellationToken); } + +[Export(typeof(VSCommanding.ICommandHandler))] +[ContentType(ContentTypeNames.RoslynContentType)] +[Name(PredefinedCommandHandlerNames.GoToBase)] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class GoToBaseCommandHandler( + IThreadingContext threadingContext, + IStreamingFindUsagesPresenter streamingPresenter, + IAsynchronousOperationListenerProvider listenerProvider, + IGlobalOptionService globalOptions) : AbstractGoOrFindCommandHandler( + new GoToBaseNavigationService(threadingContext, streamingPresenter, listenerProvider, globalOptions); diff --git a/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs b/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs index b4fa92d69ce86..d968fe89233be 100644 --- a/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs +++ b/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs @@ -5,564 +5,54 @@ using System; using System.Threading; using System.Threading.Tasks; -using Microsoft.CodeAnalysis.Classification; -using Microsoft.CodeAnalysis.Editor.Host; using Microsoft.CodeAnalysis.Editor.Shared.Extensions; -using Microsoft.CodeAnalysis.Editor.Shared.Utilities; -using Microsoft.CodeAnalysis.ErrorReporting; -using Microsoft.CodeAnalysis.FindUsages; using Microsoft.CodeAnalysis.Host; -using Microsoft.CodeAnalysis.Internal.Log; -using Microsoft.CodeAnalysis.Notification; -using Microsoft.CodeAnalysis.Options; -using Microsoft.CodeAnalysis.Shared.Extensions; -using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.CodeAnalysis.Text; -using Microsoft.CodeAnalysis.Threading; using Microsoft.VisualStudio.Commanding; -using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Text.Editor.Commanding; -using Microsoft.VisualStudio.Threading; namespace Microsoft.CodeAnalysis.GoToDefinition; -internal abstract class AbstractGoOrFindService( - IThreadingContext threadingContext, - IStreamingFindUsagesPresenter streamingPresenter, - IAsynchronousOperationListener listener, - IGlobalOptionService globalOptions) - where TLanguageService : class, ILanguageService -{ - private readonly IThreadingContext _threadingContext = threadingContext; - private readonly IStreamingFindUsagesPresenter _streamingPresenter = streamingPresenter; - private readonly IAsynchronousOperationListener _listener = listener; - - public readonly OptionsProvider ClassificationOptionsProvider = globalOptions.GetClassificationOptionsProvider(); - - /// - /// The current go-to command that is in progress. Tracked so that if we issue multiple find-impl commands that - /// they properly run after each other. This is necessary so none of them accidentally stomp on one that is still - /// in progress and is interacting with the UI. Only valid to read or write to this on the UI thread. - /// - private Task _inProgressCommand = Task.CompletedTask; - - /// - /// CancellationToken governing the current . Only valid to read or write to this - /// on the UI thread. - /// - /// - /// Cancellation is complicated with this feature. There are two things that can cause us to cancel. The first is - /// if the user kicks off another actual go-to-impl command. In that case, we just attempt to cancel the prior - /// command (if it is still running), then wait for it to complete, then run our command. The second is if we have - /// switched over to the streaming presenter and then the user starts some other command (like FAR) that takes over - /// the presenter. In that case, the presenter will notify us that it has be re-purposed and we will also cancel - /// this source. - /// - private readonly CancellationSeries _cancellationSeries = new(threadingContext.DisposalToken); - - /// - /// This hook allows for stabilizing the asynchronous nature of this command handler for integration testing. - /// - private Func? _delayHook; - - public abstract string DisplayName { get; } - - protected abstract FunctionId FunctionId { get; } - - /// - /// If we should try to navigate to the sole item found, if that item was found within 1.5seconds. - /// - protected abstract bool NavigateToSingleResultIfQuick { get; } - - protected virtual StreamingFindUsagesPresenterOptions GetStreamingPresenterOptions(Document document) - => StreamingFindUsagesPresenterOptions.Default; - - protected abstract Task FindActionAsync(IFindUsagesContext context, Document document, TLanguageService service, int caretPosition, CancellationToken cancellationToken); - - private static (Document?, TLanguageService?) GetDocumentAndService(ITextSnapshot snapshot) - { - var document = snapshot.GetOpenDocumentInCurrentContextWithChanges(); - return (document, document?.GetLanguageService()); - } - - public bool IsAvailable(Document document) - { - return document?.GetLanguageService() != null; - } - - public bool ExecuteCommand(Document document, int position) - { - _threadingContext.ThrowIfNotOnUIThread(); - if (document is null) - return false; - - var service = document?.GetLanguageService(); - if (service == null) - return false; - - // cancel any prior find-refs that might be in progress. - var cancellationToken = _cancellationSeries.CreateNext(); - - // we're going to return immediately from ExecuteCommand and kick off our own async work to invoke the - // operation. Once this returns, the editor will close the threaded wait dialog it created. - _inProgressCommand = ExecuteCommandAsync(document, service, position, cancellationToken); - return true; - } - - private async Task ExecuteCommandAsync( - Document document, - TLanguageService service, - int position, - CancellationToken cancellationToken) - { - // This is a fire-and-forget method (nothing guarantees observing it). As such, we have to handle cancellation - // and failure ourselves. - try - { - _threadingContext.ThrowIfNotOnUIThread(); - - // Make an tracking token so that integration tests can wait until we're complete. - using var token = _listener.BeginAsyncOperation($"{GetType().Name}.{nameof(ExecuteCommandAsync)}"); - - // Only start running once the previous command has finished. That way we don't have results from both - // potentially interleaving with each other. Note: this should ideally always be fast as long as the prior - // task respects cancellation. - // - // Note: we just need to make sure we run after that prior command finishes. We do not want to propagate - // any failures from it. Technically this should not be possible as it should be inside this same - // try/catch. however this code wants to be very resilient to any prior mistakes infecting later operations. - await _inProgressCommand.NoThrowAwaitable(captureContext: false); - await ExecuteCommandWorkerAsync(document, service, position, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - catch (Exception ex) when (FatalError.ReportAndCatch(ex)) - { - } - } - - private async Task ExecuteCommandWorkerAsync( - Document document, - TLanguageService service, - int position, - CancellationToken cancellationToken) - { - // Switch to the BG immediately so we can keep as much work off the UI thread. - await TaskScheduler.Default; - - // We kick off the work to find the impl/base in the background. If we get the results for it within 1.5 - // seconds, we then either navigate directly to it (in the case of one result), or we show all the results in - // the presenter (in the case of multiple). - // - // However, if the results don't come back in 1.5 seconds, we just pop open the presenter and continue the - // search there. That way the user is not blocked and can go do other work if they want. - - // We create our own context object, simply to capture all the definitions reported by the individual - // TLanguageService. Once we get the results back we'll then decide what to do with them. If we get only a - // single result back, then we'll just go directly to it. Otherwise, we'll present the results in the - // IStreamingFindUsagesPresenter. - var findContext = new BufferedFindUsagesContext(); - - var delayBeforeShowingResultsWindowTask = DelayAsync(cancellationToken); - var findTask = FindResultsAsync(findContext, document, service, position, cancellationToken); - - var firstFinishedTask = await Task.WhenAny(delayBeforeShowingResultsWindowTask, findTask).ConfigureAwait(false); - if (cancellationToken.IsCancellationRequested) - // we bailed out because another command was issued. Immediately stop everything we're doing and return - // back so the next operation can run. - return; - - if (this.NavigateToSingleResultIfQuick && firstFinishedTask == findTask) - { - // We completed the search within 1.5 seconds. If we had at least one result then Navigate to it directly - // (if there is just one) or present them all if there are many. - var definitions = await findContext.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false); - if (definitions.Length > 0) - { - var title = await findContext.GetSearchTitleAsync(cancellationToken).ConfigureAwait(false); - await _streamingPresenter.TryPresentLocationOrNavigateIfOneAsync( - _threadingContext, - document.Project.Solution.Workspace, - title ?? DisplayName, - definitions, - cancellationToken).ConfigureAwait(false); - return; - } - } - - // We either got no results, or 1.5 has passed and we didn't figure out the symbols to navigate to or - // present. So pop up the presenter to show the user that we're involved in a longer search, without - // blocking them. - await PresentResultsInStreamingPresenterAsync(document, findContext, findTask, cancellationToken).ConfigureAwait(false); - } - - private Task DelayAsync(CancellationToken cancellationToken) - { - if (_delayHook is { } delayHook) - { - return delayHook(cancellationToken); - } - - // If we want to navigate to a single result if it is found quickly, then delay showing the find-refs winfor - // for 1.5 seconds to see if a result comes in by then. If we're not navigating and are always showing the - // far window, then don't have any delay showing the window. - var delay = this.NavigateToSingleResultIfQuick - ? DelayTimeSpan.Idle - : TimeSpan.Zero; - - return Task.Delay(delay, cancellationToken); - } - - private async Task PresentResultsInStreamingPresenterAsync( - Document document, - BufferedFindUsagesContext findContext, - Task findTask, - CancellationToken cancellationToken) - { - await _threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); - var (presenterContext, presenterCancellationToken) = _streamingPresenter.StartSearch(DisplayName, GetStreamingPresenterOptions(document)); - - try - { - await TaskScheduler.Default; - - // Now, tell our find-context (which has been collecting intermediary results) to swap over to using the - // actual presenter context. It will push all results it's been collecting into that, and from that - // point onwards will just forward any new results directly to the presenter. - await findContext.AttachToStreamingPresenterAsync(presenterContext, cancellationToken).ConfigureAwait(false); - - // Hook up the presenter's cancellation token to our overall governing cancellation token. In other - // words, if something else decides to present in the presenter (like a find-refs call) we'll hear about - // that and can cancel all our work. - presenterCancellationToken.Register(() => _cancellationSeries.CreateNext()); - - // now actually wait for the find work to be done. - await findTask.ConfigureAwait(false); - } - finally - { - // Ensure that once we pop up the presenter, we always make sure to force it to the completed stage in - // case some other find operation happens (either through this handler or another handler using the - // presenter) and we don't actually finish the search. - await presenterContext.OnCompletedAsync(cancellationToken).ConfigureAwait(false); - } - } - - private async Task FindResultsAsync( - IFindUsagesContext findContext, Document document, TLanguageService service, int position, CancellationToken cancellationToken) - { - // Ensure that we relinquish the thread so that the caller can proceed with their work. - await TaskScheduler.Default.SwitchTo(alwaysYield: true); - - using (Logger.LogBlock(FunctionId, KeyValueLogMessage.Create(LogType.UserAction), cancellationToken)) - { - await findContext.SetSearchTitleAsync(DisplayName, cancellationToken).ConfigureAwait(false); - - // Let the user know in the FAR window if results may be inaccurate because this is running prior to the - // solution being fully loaded. - var statusService = document.Project.Solution.Services.GetRequiredService(); - var isFullyLoaded = await statusService.IsFullyLoadedAsync(cancellationToken).ConfigureAwait(false); - if (!isFullyLoaded) - { - await findContext.ReportMessageAsync( - EditorFeaturesResources.The_results_may_be_incomplete_due_to_the_solution_still_loading_projects, NotificationSeverity.Information, cancellationToken).ConfigureAwait(false); - } - - // We were able to find the doc prior to loading the workspace (or else we would not have the service). - // So we better be able to find it afterwards. - await FindActionAsync(findContext, document, service, position, cancellationToken).ConfigureAwait(false); - } - } - - internal TestAccessor GetTestAccessor() - { - return new TestAccessor(this); - } - - internal readonly struct TestAccessor - { - private readonly AbstractGoOrFindService _instance; - - internal TestAccessor(AbstractGoOrFindService instance) - => _instance = instance; - - internal ref Func? DelayHook - => ref _instance._delayHook; - } -} - internal abstract class AbstractGoOrFindCommandHandler( - IThreadingContext threadingContext, - IStreamingFindUsagesPresenter streamingPresenter, - IAsynchronousOperationListener listener, - IGlobalOptionService globalOptions) : ICommandHandler + AbstractGoOrFindNavigationService navigationService) : ICommandHandler where TLanguageService : class, ILanguageService where TCommandArgs : EditorCommandArgs { - private readonly IThreadingContext _threadingContext = threadingContext; - private readonly IStreamingFindUsagesPresenter _streamingPresenter = streamingPresenter; - private readonly IAsynchronousOperationListener _listener = listener; - - public readonly OptionsProvider ClassificationOptionsProvider = globalOptions.GetClassificationOptionsProvider(); - - /// - /// The current go-to command that is in progress. Tracked so that if we issue multiple find-impl commands that - /// they properly run after each other. This is necessary so none of them accidentally stomp on one that is still - /// in progress and is interacting with the UI. Only valid to read or write to this on the UI thread. - /// - private Task _inProgressCommand = Task.CompletedTask; - - /// - /// CancellationToken governing the current . Only valid to read or write to this - /// on the UI thread. - /// - /// - /// Cancellation is complicated with this feature. There are two things that can cause us to cancel. The first is - /// if the user kicks off another actual go-to-impl command. In that case, we just attempt to cancel the prior - /// command (if it is still running), then wait for it to complete, then run our command. The second is if we have - /// switched over to the streaming presenter and then the user starts some other command (like FAR) that takes over - /// the presenter. In that case, the presenter will notify us that it has be re-purposed and we will also cancel - /// this source. - /// - private readonly CancellationSeries _cancellationSeries = new(threadingContext.DisposalToken); - - /// - /// This hook allows for stabilizing the asynchronous nature of this command handler for integration testing. - /// - private Func? _delayHook; - - public abstract string DisplayName { get; } - - protected abstract FunctionId FunctionId { get; } + private readonly AbstractGoOrFindNavigationService _navigationService = navigationService; - /// - /// If we should try to navigate to the sole item found, if that item was found within 1.5seconds. - /// - protected abstract bool NavigateToSingleResultIfQuick { get; } - - protected virtual StreamingFindUsagesPresenterOptions GetStreamingPresenterOptions(Document document) - => StreamingFindUsagesPresenterOptions.Default; - - protected abstract Task FindActionAsync(IFindUsagesContext context, Document document, TLanguageService service, int caretPosition, CancellationToken cancellationToken); - - private static (Document?, TLanguageService?) GetDocumentAndService(ITextSnapshot snapshot) - { - var document = snapshot.GetOpenDocumentInCurrentContextWithChanges(); - return (document, document?.GetLanguageService()); - } + public string DisplayName => _navigationService.DisplayName; public CommandState GetCommandState(TCommandArgs args) { - var (_, service) = GetDocumentAndService(args.SubjectBuffer.CurrentSnapshot); - return service != null + var document = args.SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges(); + return _navigationService.IsAvailable(document) ? CommandState.Available : CommandState.Unspecified; } public bool ExecuteCommand(TCommandArgs args, CommandExecutionContext context) { - _threadingContext.ThrowIfNotOnUIThread(); + _navigationService.ThreadingContext.ThrowIfNotOnUIThread(); var subjectBuffer = args.SubjectBuffer; var caret = args.TextView.GetCaretPoint(subjectBuffer); if (!caret.HasValue) return false; - var (document, service) = GetDocumentAndService(subjectBuffer.CurrentSnapshot); - if (service == null) + var document = args.SubjectBuffer.CurrentSnapshot.GetOpenDocumentInCurrentContextWithChanges(); + if (!_navigationService.IsAvailable(document)) return false; - Contract.ThrowIfNull(document); - - // cancel any prior find-refs that might be in progress. - var cancellationToken = _cancellationSeries.CreateNext(); - - // we're going to return immediately from ExecuteCommand and kick off our own async work to invoke the - // operation. Once this returns, the editor will close the threaded wait dialog it created. - _inProgressCommand = ExecuteCommandAsync(document, service, caret.Value.Position, cancellationToken); + _navigationService.ExecuteCommand(document, caret.Value.Position); return true; } - private async Task ExecuteCommandAsync( - Document document, - TLanguageService service, - int position, - CancellationToken cancellationToken) - { - // This is a fire-and-forget method (nothing guarantees observing it). As such, we have to handle cancellation - // and failure ourselves. - try - { - _threadingContext.ThrowIfNotOnUIThread(); - - // Make an tracking token so that integration tests can wait until we're complete. - using var token = _listener.BeginAsyncOperation($"{GetType().Name}.{nameof(ExecuteCommandAsync)}"); - - // Only start running once the previous command has finished. That way we don't have results from both - // potentially interleaving with each other. Note: this should ideally always be fast as long as the prior - // task respects cancellation. - // - // Note: we just need to make sure we run after that prior command finishes. We do not want to propagate - // any failures from it. Technically this should not be possible as it should be inside this same - // try/catch. however this code wants to be very resilient to any prior mistakes infecting later operations. - await _inProgressCommand.NoThrowAwaitable(captureContext: false); - await ExecuteCommandWorkerAsync(document, service, position, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - catch (Exception ex) when (FatalError.ReportAndCatch(ex)) - { - } - } - - private async Task ExecuteCommandWorkerAsync( - Document document, - TLanguageService service, - int position, - CancellationToken cancellationToken) - { - // Switch to the BG immediately so we can keep as much work off the UI thread. - await TaskScheduler.Default; - - // We kick off the work to find the impl/base in the background. If we get the results for it within 1.5 - // seconds, we then either navigate directly to it (in the case of one result), or we show all the results in - // the presenter (in the case of multiple). - // - // However, if the results don't come back in 1.5 seconds, we just pop open the presenter and continue the - // search there. That way the user is not blocked and can go do other work if they want. - - // We create our own context object, simply to capture all the definitions reported by the individual - // TLanguageService. Once we get the results back we'll then decide what to do with them. If we get only a - // single result back, then we'll just go directly to it. Otherwise, we'll present the results in the - // IStreamingFindUsagesPresenter. - var findContext = new BufferedFindUsagesContext(); - - var delayBeforeShowingResultsWindowTask = DelayAsync(cancellationToken); - var findTask = FindResultsAsync(findContext, document, service, position, cancellationToken); - - var firstFinishedTask = await Task.WhenAny(delayBeforeShowingResultsWindowTask, findTask).ConfigureAwait(false); - if (cancellationToken.IsCancellationRequested) - // we bailed out because another command was issued. Immediately stop everything we're doing and return - // back so the next operation can run. - return; - - if (this.NavigateToSingleResultIfQuick && firstFinishedTask == findTask) - { - // We completed the search within 1.5 seconds. If we had at least one result then Navigate to it directly - // (if there is just one) or present them all if there are many. - var definitions = await findContext.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false); - if (definitions.Length > 0) - { - var title = await findContext.GetSearchTitleAsync(cancellationToken).ConfigureAwait(false); - await _streamingPresenter.TryPresentLocationOrNavigateIfOneAsync( - _threadingContext, - document.Project.Solution.Workspace, - title ?? DisplayName, - definitions, - cancellationToken).ConfigureAwait(false); - return; - } - } - - // We either got no results, or 1.5 has passed and we didn't figure out the symbols to navigate to or - // present. So pop up the presenter to show the user that we're involved in a longer search, without - // blocking them. - await PresentResultsInStreamingPresenterAsync(document, findContext, findTask, cancellationToken).ConfigureAwait(false); - } - - private Task DelayAsync(CancellationToken cancellationToken) - { - if (_delayHook is { } delayHook) - { - return delayHook(cancellationToken); - } + public TestAccessor GetTestAccessor() + => new(this); - // If we want to navigate to a single result if it is found quickly, then delay showing the find-refs winfor - // for 1.5 seconds to see if a result comes in by then. If we're not navigating and are always showing the - // far window, then don't have any delay showing the window. - var delay = this.NavigateToSingleResultIfQuick - ? DelayTimeSpan.Idle - : TimeSpan.Zero; - - return Task.Delay(delay, cancellationToken); - } - - private async Task PresentResultsInStreamingPresenterAsync( - Document document, - BufferedFindUsagesContext findContext, - Task findTask, - CancellationToken cancellationToken) + public readonly struct TestAccessor(AbstractGoOrFindCommandHandler instance) { - await _threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); - var (presenterContext, presenterCancellationToken) = _streamingPresenter.StartSearch(DisplayName, GetStreamingPresenterOptions(document)); - - try - { - await TaskScheduler.Default; - - // Now, tell our find-context (which has been collecting intermediary results) to swap over to using the - // actual presenter context. It will push all results it's been collecting into that, and from that - // point onwards will just forward any new results directly to the presenter. - await findContext.AttachToStreamingPresenterAsync(presenterContext, cancellationToken).ConfigureAwait(false); - - // Hook up the presenter's cancellation token to our overall governing cancellation token. In other - // words, if something else decides to present in the presenter (like a find-refs call) we'll hear about - // that and can cancel all our work. - presenterCancellationToken.Register(() => _cancellationSeries.CreateNext()); - - // now actually wait for the find work to be done. - await findTask.ConfigureAwait(false); - } - finally - { - // Ensure that once we pop up the presenter, we always make sure to force it to the completed stage in - // case some other find operation happens (either through this handler or another handler using the - // presenter) and we don't actually finish the search. - await presenterContext.OnCompletedAsync(cancellationToken).ConfigureAwait(false); - } - } - - private async Task FindResultsAsync( - IFindUsagesContext findContext, Document document, TLanguageService service, int position, CancellationToken cancellationToken) - { - // Ensure that we relinquish the thread so that the caller can proceed with their work. - await TaskScheduler.Default.SwitchTo(alwaysYield: true); - - using (Logger.LogBlock(FunctionId, KeyValueLogMessage.Create(LogType.UserAction), cancellationToken)) - { - await findContext.SetSearchTitleAsync(DisplayName, cancellationToken).ConfigureAwait(false); - - // Let the user know in the FAR window if results may be inaccurate because this is running prior to the - // solution being fully loaded. - var statusService = document.Project.Solution.Services.GetRequiredService(); - var isFullyLoaded = await statusService.IsFullyLoadedAsync(cancellationToken).ConfigureAwait(false); - if (!isFullyLoaded) - { - await findContext.ReportMessageAsync( - EditorFeaturesResources.The_results_may_be_incomplete_due_to_the_solution_still_loading_projects, NotificationSeverity.Information, cancellationToken).ConfigureAwait(false); - } - - // We were able to find the doc prior to loading the workspace (or else we would not have the service). - // So we better be able to find it afterwards. - await FindActionAsync(findContext, document, service, position, cancellationToken).ConfigureAwait(false); - } - } - - internal TestAccessor GetTestAccessor() - { - return new TestAccessor(this); - } - - internal readonly struct TestAccessor - { - private readonly AbstractGoOrFindCommandHandler _instance; - - internal TestAccessor(AbstractGoOrFindCommandHandler instance) - => _instance = instance; - - internal ref Func? DelayHook - => ref _instance._delayHook; + public void SetDelayHook(Func hook) + => instance._navigationService.GetTestAccessor().DelayHook = hook; } } diff --git a/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindNavigationService.cs b/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindNavigationService.cs new file mode 100644 index 0000000000000..7c3e5973c83de --- /dev/null +++ b/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindNavigationService.cs @@ -0,0 +1,284 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Classification; +using Microsoft.CodeAnalysis.Editor.Host; +using Microsoft.CodeAnalysis.Editor.Shared.Extensions; +using Microsoft.CodeAnalysis.Editor.Shared.Utilities; +using Microsoft.CodeAnalysis.ErrorReporting; +using Microsoft.CodeAnalysis.FindUsages; +using Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Internal.Log; +using Microsoft.CodeAnalysis.Notification; +using Microsoft.CodeAnalysis.Options; +using Microsoft.CodeAnalysis.Shared.Extensions; +using Microsoft.CodeAnalysis.Shared.TestHooks; +using Microsoft.CodeAnalysis.Threading; +using Microsoft.VisualStudio.Threading; + +namespace Microsoft.CodeAnalysis.GoToDefinition; + +internal abstract class AbstractGoOrFindNavigationService( + IThreadingContext threadingContext, + IStreamingFindUsagesPresenter streamingPresenter, + IAsynchronousOperationListener listener, + IGlobalOptionService globalOptions) + where TLanguageService : class, ILanguageService +{ + public readonly IThreadingContext ThreadingContext = threadingContext; + private readonly IStreamingFindUsagesPresenter _streamingPresenter = streamingPresenter; + private readonly IAsynchronousOperationListener _listener = listener; + + public readonly OptionsProvider ClassificationOptionsProvider = globalOptions.GetClassificationOptionsProvider(); + + /// + /// The current go-to command that is in progress. Tracked so that if we issue multiple find-impl commands that + /// they properly run after each other. This is necessary so none of them accidentally stomp on one that is still + /// in progress and is interacting with the UI. Only valid to read or write to this on the UI thread. + /// + private Task _inProgressCommand = Task.CompletedTask; + + /// + /// CancellationToken governing the current . Only valid to read or write to this + /// on the UI thread. + /// + /// + /// Cancellation is complicated with this feature. There are two things that can cause us to cancel. The first is + /// if the user kicks off another actual go-to-impl command. In that case, we just attempt to cancel the prior + /// command (if it is still running), then wait for it to complete, then run our command. The second is if we have + /// switched over to the streaming presenter and then the user starts some other command (like FAR) that takes over + /// the presenter. In that case, the presenter will notify us that it has be re-purposed and we will also cancel + /// this source. + /// + private readonly CancellationSeries _cancellationSeries = new(threadingContext.DisposalToken); + + /// + /// This hook allows for stabilizing the asynchronous nature of this command handler for integration testing. + /// + private Func? _delayHook; + + public abstract string DisplayName { get; } + + protected abstract FunctionId FunctionId { get; } + + /// + /// If we should try to navigate to the sole item found, if that item was found within 1.5seconds. + /// + protected abstract bool NavigateToSingleResultIfQuick { get; } + + protected virtual StreamingFindUsagesPresenterOptions GetStreamingPresenterOptions(Document document) + => StreamingFindUsagesPresenterOptions.Default; + + protected abstract Task FindActionAsync(IFindUsagesContext context, Document document, TLanguageService service, int caretPosition, CancellationToken cancellationToken); + +#pragma warning disable CA1822 // Mark members as static + public bool IsAvailable([NotNullWhen(true)] Document? document) + => document?.GetLanguageService() != null; +#pragma warning restore CA1822 // Mark members as static + + public bool ExecuteCommand(Document document, int position) + { + ThreadingContext.ThrowIfNotOnUIThread(); + if (document is null) + return false; + + var service = document.GetLanguageService(); + if (service == null) + return false; + + // cancel any prior find-refs that might be in progress. + var cancellationToken = _cancellationSeries.CreateNext(); + + // we're going to return immediately from ExecuteCommand and kick off our own async work to invoke the + // operation. Once this returns, the editor will close the threaded wait dialog it created. + _inProgressCommand = ExecuteCommandAsync(document, service, position, cancellationToken); + return true; + } + + private async Task ExecuteCommandAsync( + Document document, + TLanguageService service, + int position, + CancellationToken cancellationToken) + { + // This is a fire-and-forget method (nothing guarantees observing it). As such, we have to handle cancellation + // and failure ourselves. + try + { + ThreadingContext.ThrowIfNotOnUIThread(); + + // Make an tracking token so that integration tests can wait until we're complete. + using var token = _listener.BeginAsyncOperation($"{GetType().Name}.{nameof(ExecuteCommandAsync)}"); + + // Only start running once the previous command has finished. That way we don't have results from both + // potentially interleaving with each other. Note: this should ideally always be fast as long as the prior + // task respects cancellation. + // + // Note: we just need to make sure we run after that prior command finishes. We do not want to propagate + // any failures from it. Technically this should not be possible as it should be inside this same + // try/catch. however this code wants to be very resilient to any prior mistakes infecting later operations. + await _inProgressCommand.NoThrowAwaitable(captureContext: false); + await ExecuteCommandWorkerAsync(document, service, position, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) when (FatalError.ReportAndCatch(ex)) + { + } + } + + private async Task ExecuteCommandWorkerAsync( + Document document, + TLanguageService service, + int position, + CancellationToken cancellationToken) + { + // Switch to the BG immediately so we can keep as much work off the UI thread. + await TaskScheduler.Default; + + // We kick off the work to find the impl/base in the background. If we get the results for it within 1.5 + // seconds, we then either navigate directly to it (in the case of one result), or we show all the results in + // the presenter (in the case of multiple). + // + // However, if the results don't come back in 1.5 seconds, we just pop open the presenter and continue the + // search there. That way the user is not blocked and can go do other work if they want. + + // We create our own context object, simply to capture all the definitions reported by the individual + // TLanguageService. Once we get the results back we'll then decide what to do with them. If we get only a + // single result back, then we'll just go directly to it. Otherwise, we'll present the results in the + // IStreamingFindUsagesPresenter. + var findContext = new BufferedFindUsagesContext(); + + var delayBeforeShowingResultsWindowTask = DelayAsync(cancellationToken); + var findTask = FindResultsAsync(findContext, document, service, position, cancellationToken); + + var firstFinishedTask = await Task.WhenAny(delayBeforeShowingResultsWindowTask, findTask).ConfigureAwait(false); + if (cancellationToken.IsCancellationRequested) + // we bailed out because another command was issued. Immediately stop everything we're doing and return + // back so the next operation can run. + return; + + if (this.NavigateToSingleResultIfQuick && firstFinishedTask == findTask) + { + // We completed the search within 1.5 seconds. If we had at least one result then Navigate to it directly + // (if there is just one) or present them all if there are many. + var definitions = await findContext.GetDefinitionsAsync(cancellationToken).ConfigureAwait(false); + if (definitions.Length > 0) + { + var title = await findContext.GetSearchTitleAsync(cancellationToken).ConfigureAwait(false); + await _streamingPresenter.TryPresentLocationOrNavigateIfOneAsync( + ThreadingContext, + document.Project.Solution.Workspace, + title ?? DisplayName, + definitions, + cancellationToken).ConfigureAwait(false); + return; + } + } + + // We either got no results, or 1.5 has passed and we didn't figure out the symbols to navigate to or + // present. So pop up the presenter to show the user that we're involved in a longer search, without + // blocking them. + await PresentResultsInStreamingPresenterAsync(document, findContext, findTask, cancellationToken).ConfigureAwait(false); + } + + private Task DelayAsync(CancellationToken cancellationToken) + { + if (_delayHook is { } delayHook) + { + return delayHook(cancellationToken); + } + + // If we want to navigate to a single result if it is found quickly, then delay showing the find-refs winfor + // for 1.5 seconds to see if a result comes in by then. If we're not navigating and are always showing the + // far window, then don't have any delay showing the window. + var delay = this.NavigateToSingleResultIfQuick + ? DelayTimeSpan.Idle + : TimeSpan.Zero; + + return Task.Delay(delay, cancellationToken); + } + + private async Task PresentResultsInStreamingPresenterAsync( + Document document, + BufferedFindUsagesContext findContext, + Task findTask, + CancellationToken cancellationToken) + { + await ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + var (presenterContext, presenterCancellationToken) = _streamingPresenter.StartSearch(DisplayName, GetStreamingPresenterOptions(document)); + + try + { + await TaskScheduler.Default; + + // Now, tell our find-context (which has been collecting intermediary results) to swap over to using the + // actual presenter context. It will push all results it's been collecting into that, and from that + // point onwards will just forward any new results directly to the presenter. + await findContext.AttachToStreamingPresenterAsync(presenterContext, cancellationToken).ConfigureAwait(false); + + // Hook up the presenter's cancellation token to our overall governing cancellation token. In other + // words, if something else decides to present in the presenter (like a find-refs call) we'll hear about + // that and can cancel all our work. + presenterCancellationToken.Register(() => _cancellationSeries.CreateNext()); + + // now actually wait for the find work to be done. + await findTask.ConfigureAwait(false); + } + finally + { + // Ensure that once we pop up the presenter, we always make sure to force it to the completed stage in + // case some other find operation happens (either through this handler or another handler using the + // presenter) and we don't actually finish the search. + await presenterContext.OnCompletedAsync(cancellationToken).ConfigureAwait(false); + } + } + + private async Task FindResultsAsync( + IFindUsagesContext findContext, Document document, TLanguageService service, int position, CancellationToken cancellationToken) + { + // Ensure that we relinquish the thread so that the caller can proceed with their work. + await TaskScheduler.Default.SwitchTo(alwaysYield: true); + + using (Logger.LogBlock(FunctionId, KeyValueLogMessage.Create(LogType.UserAction), cancellationToken)) + { + await findContext.SetSearchTitleAsync(DisplayName, cancellationToken).ConfigureAwait(false); + + // Let the user know in the FAR window if results may be inaccurate because this is running prior to the + // solution being fully loaded. + var statusService = document.Project.Solution.Services.GetRequiredService(); + var isFullyLoaded = await statusService.IsFullyLoadedAsync(cancellationToken).ConfigureAwait(false); + if (!isFullyLoaded) + { + await findContext.ReportMessageAsync( + EditorFeaturesResources.The_results_may_be_incomplete_due_to_the_solution_still_loading_projects, NotificationSeverity.Information, cancellationToken).ConfigureAwait(false); + } + + // We were able to find the doc prior to loading the workspace (or else we would not have the service). + // So we better be able to find it afterwards. + await FindActionAsync(findContext, document, service, position, cancellationToken).ConfigureAwait(false); + } + } + + internal TestAccessor GetTestAccessor() + { + return new TestAccessor(this); + } + + internal readonly struct TestAccessor + { + private readonly AbstractGoOrFindNavigationService _instance; + + internal TestAccessor(AbstractGoOrFindNavigationService instance) + => _instance = instance; + + internal ref Func? DelayHook + => ref _instance._delayHook; + } +} From bb4403fb5d18d4b79de10fadd986481a3aab210c Mon Sep 17 00:00:00 2001 From: Cyrus Najmabadi Date: Thu, 29 May 2025 15:50:11 +0200 Subject: [PATCH 3/8] in progress --- .../Core/GoToBase/GoToBaseCommandHandler.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs b/src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs index 8092110ab9ae0..f365aa86c3464 100644 --- a/src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs +++ b/src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs @@ -21,6 +21,9 @@ namespace Microsoft.CodeAnalysis.GoToBase; +[Export(typeof(GoToBaseNavigationService))] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] internal sealed class GoToBaseNavigationService( IThreadingContext threadingContext, IStreamingFindUsagesPresenter streamingPresenter, @@ -51,9 +54,5 @@ protected override Task FindActionAsync(IFindUsagesContext context, Document doc [Name(PredefinedCommandHandlerNames.GoToBase)] [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] -internal sealed class GoToBaseCommandHandler( - IThreadingContext threadingContext, - IStreamingFindUsagesPresenter streamingPresenter, - IAsynchronousOperationListenerProvider listenerProvider, - IGlobalOptionService globalOptions) : AbstractGoOrFindCommandHandler( - new GoToBaseNavigationService(threadingContext, streamingPresenter, listenerProvider, globalOptions); +internal sealed class GoToBaseCommandHandler(GoToBaseNavigationService navigationService) + : AbstractGoOrFindCommandHandler(navigationService); From 767f1de8bf8656b38a3f1807e04d5b3e1a8e3001 Mon Sep 17 00:00:00 2001 From: Cyrus Najmabadi Date: Thu, 29 May 2025 15:59:20 +0200 Subject: [PATCH 4/8] Break out types --- .../AbstractGoOrFindCommandHandler.cs | 27 ++++--------------- .../AbstractGoOrFindNavigationService.cs | 5 ++-- .../FindReferencesCommandHandler.cs | 18 ++++++++----- .../GoToBase/GoToBaseCommandHandler.cs | 22 +++++++++++++++ .../GoToBase/GoToBaseNavigationService.cs} | 14 +--------- .../GoToImplementationCommandHandler.cs | 23 ++++++++++++++++ .../GoToImplementationNavigationService.cs} | 14 +++------- .../GoOrFind/IGoOrFindNavigationService.cs | 15 +++++++++++ 8 files changed, 84 insertions(+), 54 deletions(-) rename src/EditorFeatures/Core/{GoToDefinition => GoOrFind}/AbstractGoOrFindCommandHandler.cs (55%) rename src/EditorFeatures/Core/{GoToDefinition => GoOrFind}/AbstractGoOrFindNavigationService.cs (98%) rename src/EditorFeatures/Core/{ => GoOrFind}/FindReferences/FindReferencesCommandHandler.cs (80%) create mode 100644 src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseCommandHandler.cs rename src/EditorFeatures/Core/{GoToBase/GoToBaseCommandHandler.cs => GoOrFind/GoToBase/GoToBaseNavigationService.cs} (74%) create mode 100644 src/EditorFeatures/Core/GoOrFind/GoToImplementation/GoToImplementationCommandHandler.cs rename src/EditorFeatures/Core/{GoToImplementation/GoToImplementationCommandHandler.cs => GoOrFind/GoToImplementation/GoToImplementationNavigationService.cs} (77%) create mode 100644 src/EditorFeatures/Core/GoOrFind/IGoOrFindNavigationService.cs diff --git a/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindCommandHandler.cs similarity index 55% rename from src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs rename to src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindCommandHandler.cs index d968fe89233be..200abe79c705d 100644 --- a/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindCommandHandler.cs +++ b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindCommandHandler.cs @@ -2,23 +2,18 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.CodeAnalysis.Editor.Shared.Extensions; -using Microsoft.CodeAnalysis.Host; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.Commanding; using Microsoft.VisualStudio.Text.Editor.Commanding; -namespace Microsoft.CodeAnalysis.GoToDefinition; +namespace Microsoft.CodeAnalysis.GoOrFind; -internal abstract class AbstractGoOrFindCommandHandler( - AbstractGoOrFindNavigationService navigationService) : ICommandHandler - where TLanguageService : class, ILanguageService +internal abstract class AbstractGoOrFindCommandHandler( + IGoOrFindNavigationService navigationService) : ICommandHandler where TCommandArgs : EditorCommandArgs { - private readonly AbstractGoOrFindNavigationService _navigationService = navigationService; + private readonly IGoOrFindNavigationService _navigationService = navigationService; public string DisplayName => _navigationService.DisplayName; @@ -32,8 +27,6 @@ public CommandState GetCommandState(TCommandArgs args) public bool ExecuteCommand(TCommandArgs args, CommandExecutionContext context) { - _navigationService.ThreadingContext.ThrowIfNotOnUIThread(); - var subjectBuffer = args.SubjectBuffer; var caret = args.TextView.GetCaretPoint(subjectBuffer); if (!caret.HasValue) @@ -43,16 +36,6 @@ public bool ExecuteCommand(TCommandArgs args, CommandExecutionContext context) if (!_navigationService.IsAvailable(document)) return false; - _navigationService.ExecuteCommand(document, caret.Value.Position); - return true; - } - - public TestAccessor GetTestAccessor() - => new(this); - - public readonly struct TestAccessor(AbstractGoOrFindCommandHandler instance) - { - public void SetDelayHook(Func hook) - => instance._navigationService.GetTestAccessor().DelayHook = hook; + return _navigationService.ExecuteCommand(document, caret.Value.Position); } } diff --git a/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindNavigationService.cs b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs similarity index 98% rename from src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindNavigationService.cs rename to src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs index 7c3e5973c83de..59dc8835f8ace 100644 --- a/src/EditorFeatures/Core/GoToDefinition/AbstractGoOrFindNavigationService.cs +++ b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs @@ -21,13 +21,14 @@ using Microsoft.CodeAnalysis.Threading; using Microsoft.VisualStudio.Threading; -namespace Microsoft.CodeAnalysis.GoToDefinition; +namespace Microsoft.CodeAnalysis.GoOrFind; internal abstract class AbstractGoOrFindNavigationService( IThreadingContext threadingContext, IStreamingFindUsagesPresenter streamingPresenter, IAsynchronousOperationListener listener, IGlobalOptionService globalOptions) + : IGoOrFindNavigationService where TLanguageService : class, ILanguageService { public readonly IThreadingContext ThreadingContext = threadingContext; @@ -76,10 +77,8 @@ protected virtual StreamingFindUsagesPresenterOptions GetStreamingPresenterOptio protected abstract Task FindActionAsync(IFindUsagesContext context, Document document, TLanguageService service, int caretPosition, CancellationToken cancellationToken); -#pragma warning disable CA1822 // Mark members as static public bool IsAvailable([NotNullWhen(true)] Document? document) => document?.GetLanguageService() != null; -#pragma warning restore CA1822 // Mark members as static public bool ExecuteCommand(Document document, int position) { diff --git a/src/EditorFeatures/Core/FindReferences/FindReferencesCommandHandler.cs b/src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesCommandHandler.cs similarity index 80% rename from src/EditorFeatures/Core/FindReferences/FindReferencesCommandHandler.cs rename to src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesCommandHandler.cs index cfea54a2a8b63..33ef65673d94d 100644 --- a/src/EditorFeatures/Core/FindReferences/FindReferencesCommandHandler.cs +++ b/src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesCommandHandler.cs @@ -10,7 +10,7 @@ using Microsoft.CodeAnalysis.Editor.Host; using Microsoft.CodeAnalysis.Editor.Shared.Utilities; using Microsoft.CodeAnalysis.FindUsages; -using Microsoft.CodeAnalysis.GoToDefinition; +using Microsoft.CodeAnalysis.GoOrFind; using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.Shared.TestHooks; @@ -20,16 +20,14 @@ namespace Microsoft.CodeAnalysis.FindReferences; -[Export(typeof(ICommandHandler))] -[ContentType(ContentTypeNames.RoslynContentType)] -[Name(PredefinedCommandHandlerNames.FindReferences)] +[Export(typeof(FindReferencesNavigationService))] [method: ImportingConstructor] [method: SuppressMessage("RoslynDiagnosticsReliability", "RS0033:Importing constructor should be [Obsolete]", Justification = "Used in test code: https://github.com/dotnet/roslyn/issues/42814")] -internal sealed class FindReferencesCommandHandler( +internal sealed class FindReferencesNavigationService( IThreadingContext threadingContext, IStreamingFindUsagesPresenter streamingPresenter, IAsynchronousOperationListenerProvider listenerProvider, - IGlobalOptionService globalOptions) : AbstractGoOrFindCommandHandler( + IGlobalOptionService globalOptions) : AbstractGoOrFindNavigationService( threadingContext, streamingPresenter, listenerProvider.GetListener(FeatureAttribute.FindReferences), @@ -57,3 +55,11 @@ protected override StreamingFindUsagesPresenterOptions GetStreamingPresenterOpti protected override Task FindActionAsync(IFindUsagesContext context, Document document, IFindUsagesService service, int caretPosition, CancellationToken cancellationToken) => service.FindReferencesAsync(context, document, caretPosition, this.ClassificationOptionsProvider, cancellationToken); } + +[Export(typeof(ICommandHandler))] +[ContentType(ContentTypeNames.RoslynContentType)] +[Name(PredefinedCommandHandlerNames.FindReferences)] +[method: ImportingConstructor] +[method: SuppressMessage("RoslynDiagnosticsReliability", "RS0033:Importing constructor should be [Obsolete]", Justification = "Used in test code: https://github.com/dotnet/roslyn/issues/42814")] +internal sealed class FindReferencesCommandHandler(FindReferencesNavigationService navigationService) + : AbstractGoOrFindCommandHandler(navigationService); diff --git a/src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseCommandHandler.cs b/src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseCommandHandler.cs new file mode 100644 index 0000000000000..a554bc93852cf --- /dev/null +++ b/src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseCommandHandler.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.ComponentModel.Composition; +using Microsoft.CodeAnalysis.Editor; +using Microsoft.CodeAnalysis.GoOrFind; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.VisualStudio.Text.Editor.Commanding.Commands; +using Microsoft.VisualStudio.Utilities; +using VSCommanding = Microsoft.VisualStudio.Commanding; + +namespace Microsoft.CodeAnalysis.GoToBase; + +[Export(typeof(VSCommanding.ICommandHandler))] +[ContentType(ContentTypeNames.RoslynContentType)] +[Name(PredefinedCommandHandlerNames.GoToBase)] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class GoToBaseCommandHandler(GoToBaseNavigationService navigationService) + : AbstractGoOrFindCommandHandler(navigationService); diff --git a/src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs b/src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseNavigationService.cs similarity index 74% rename from src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs rename to src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseNavigationService.cs index f365aa86c3464..42b5f1140a661 100644 --- a/src/EditorFeatures/Core/GoToBase/GoToBaseCommandHandler.cs +++ b/src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseNavigationService.cs @@ -6,18 +6,14 @@ using System.ComponentModel.Composition; using System.Threading; using System.Threading.Tasks; -using Microsoft.CodeAnalysis.Editor; using Microsoft.CodeAnalysis.Editor.Host; using Microsoft.CodeAnalysis.Editor.Shared.Utilities; using Microsoft.CodeAnalysis.FindUsages; -using Microsoft.CodeAnalysis.GoToDefinition; +using Microsoft.CodeAnalysis.GoOrFind; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.Shared.TestHooks; -using Microsoft.VisualStudio.Text.Editor.Commanding.Commands; -using Microsoft.VisualStudio.Utilities; -using VSCommanding = Microsoft.VisualStudio.Commanding; namespace Microsoft.CodeAnalysis.GoToBase; @@ -48,11 +44,3 @@ internal sealed class GoToBaseNavigationService( protected override Task FindActionAsync(IFindUsagesContext context, Document document, IGoToBaseService service, int caretPosition, CancellationToken cancellationToken) => service.FindBasesAsync(context, document, caretPosition, ClassificationOptionsProvider, cancellationToken); } - -[Export(typeof(VSCommanding.ICommandHandler))] -[ContentType(ContentTypeNames.RoslynContentType)] -[Name(PredefinedCommandHandlerNames.GoToBase)] -[method: ImportingConstructor] -[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] -internal sealed class GoToBaseCommandHandler(GoToBaseNavigationService navigationService) - : AbstractGoOrFindCommandHandler(navigationService); diff --git a/src/EditorFeatures/Core/GoOrFind/GoToImplementation/GoToImplementationCommandHandler.cs b/src/EditorFeatures/Core/GoOrFind/GoToImplementation/GoToImplementationCommandHandler.cs new file mode 100644 index 0000000000000..baaaa8fa487dd --- /dev/null +++ b/src/EditorFeatures/Core/GoOrFind/GoToImplementation/GoToImplementationCommandHandler.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.ComponentModel.Composition; +using Microsoft.CodeAnalysis.Editor; +using Microsoft.CodeAnalysis.Editor.Commanding.Commands; +using Microsoft.CodeAnalysis.GoOrFind; +using Microsoft.CodeAnalysis.GoToDefinition; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.VisualStudio.Commanding; +using Microsoft.VisualStudio.Utilities; + +namespace Microsoft.CodeAnalysis.GoToImplementation; + +[Export(typeof(ICommandHandler))] +[ContentType(ContentTypeNames.RoslynContentType)] +[Name(PredefinedCommandHandlerNames.GoToImplementation)] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class GoToImplementationCommandHandler(GoToImplementationNavigationService navigationService) + : AbstractGoOrFindCommandHandler(navigationService); diff --git a/src/EditorFeatures/Core/GoToImplementation/GoToImplementationCommandHandler.cs b/src/EditorFeatures/Core/GoOrFind/GoToImplementation/GoToImplementationNavigationService.cs similarity index 77% rename from src/EditorFeatures/Core/GoToImplementation/GoToImplementationCommandHandler.cs rename to src/EditorFeatures/Core/GoOrFind/GoToImplementation/GoToImplementationNavigationService.cs index 5793e5d5dd925..80406263463d4 100644 --- a/src/EditorFeatures/Core/GoToImplementation/GoToImplementationCommandHandler.cs +++ b/src/EditorFeatures/Core/GoOrFind/GoToImplementation/GoToImplementationNavigationService.cs @@ -6,31 +6,25 @@ using System.ComponentModel.Composition; using System.Threading; using System.Threading.Tasks; -using Microsoft.CodeAnalysis.Editor; -using Microsoft.CodeAnalysis.Editor.Commanding.Commands; using Microsoft.CodeAnalysis.Editor.Host; using Microsoft.CodeAnalysis.Editor.Shared.Utilities; using Microsoft.CodeAnalysis.FindUsages; -using Microsoft.CodeAnalysis.GoToDefinition; +using Microsoft.CodeAnalysis.GoOrFind; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.Internal.Log; using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.Shared.TestHooks; -using Microsoft.VisualStudio.Commanding; -using Microsoft.VisualStudio.Utilities; namespace Microsoft.CodeAnalysis.GoToImplementation; -[Export(typeof(ICommandHandler))] -[ContentType(ContentTypeNames.RoslynContentType)] -[Name(PredefinedCommandHandlerNames.GoToImplementation)] +[Export(typeof(GoToImplementationNavigationService))] [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] -internal sealed class GoToImplementationCommandHandler( +internal sealed class GoToImplementationNavigationService( IThreadingContext threadingContext, IStreamingFindUsagesPresenter streamingPresenter, IAsynchronousOperationListenerProvider listenerProvider, - IGlobalOptionService globalOptions) : AbstractGoOrFindCommandHandler( + IGlobalOptionService globalOptions) : AbstractGoOrFindNavigationService( threadingContext, streamingPresenter, listenerProvider.GetListener(FeatureAttribute.GoToImplementation), diff --git a/src/EditorFeatures/Core/GoOrFind/IGoOrFindNavigationService.cs b/src/EditorFeatures/Core/GoOrFind/IGoOrFindNavigationService.cs new file mode 100644 index 0000000000000..64a625b3dd45d --- /dev/null +++ b/src/EditorFeatures/Core/GoOrFind/IGoOrFindNavigationService.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.CodeAnalysis.GoOrFind; + +internal interface IGoOrFindNavigationService +{ + string DisplayName { get; } + + bool IsAvailable([NotNullWhen(true)] Document? document); + bool ExecuteCommand(Document document, int position); +} From adcda7301c2294e298e421b74081b8837cda4e38 Mon Sep 17 00:00:00 2001 From: Cyrus Najmabadi Date: Thu, 29 May 2025 16:07:14 +0200 Subject: [PATCH 5/8] Mop up --- .../AbstractGoOrFindNavigationService.cs | 4 ++ .../FindReferencesCommandHandler.cs | 44 --------------- .../FindReferencesNavigationService.cs | 53 +++++++++++++++++++ .../GoToBase/GoToBaseCommandHandler.cs | 2 +- .../FindReferencesCommandHandlerTests.cs | 9 ++-- .../FindReferencesCommandHandlerTests.vb | 9 ++-- .../InProcess/EditorInProcess.cs | 8 +-- 7 files changed, 72 insertions(+), 57 deletions(-) create mode 100644 src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesNavigationService.cs diff --git a/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs index 59dc8835f8ace..05427e949484e 100644 --- a/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs +++ b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs @@ -23,6 +23,10 @@ namespace Microsoft.CodeAnalysis.GoOrFind; +/// +/// Core service responsible for handling an operation (like 'go to base, go to impl, find references') +/// and trying to navigate quickly to them if possible, or show their results in the find-usages window. +/// internal abstract class AbstractGoOrFindNavigationService( IThreadingContext threadingContext, IStreamingFindUsagesPresenter streamingPresenter, diff --git a/src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesCommandHandler.cs b/src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesCommandHandler.cs index 33ef65673d94d..0cdb681762bc1 100644 --- a/src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesCommandHandler.cs +++ b/src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesCommandHandler.cs @@ -4,58 +4,14 @@ using System.ComponentModel.Composition; using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; using Microsoft.CodeAnalysis.Editor; -using Microsoft.CodeAnalysis.Editor.Host; -using Microsoft.CodeAnalysis.Editor.Shared.Utilities; -using Microsoft.CodeAnalysis.FindUsages; using Microsoft.CodeAnalysis.GoOrFind; -using Microsoft.CodeAnalysis.Internal.Log; -using Microsoft.CodeAnalysis.Options; -using Microsoft.CodeAnalysis.Shared.TestHooks; using Microsoft.VisualStudio.Commanding; using Microsoft.VisualStudio.Text.Editor.Commanding.Commands; using Microsoft.VisualStudio.Utilities; namespace Microsoft.CodeAnalysis.FindReferences; -[Export(typeof(FindReferencesNavigationService))] -[method: ImportingConstructor] -[method: SuppressMessage("RoslynDiagnosticsReliability", "RS0033:Importing constructor should be [Obsolete]", Justification = "Used in test code: https://github.com/dotnet/roslyn/issues/42814")] -internal sealed class FindReferencesNavigationService( - IThreadingContext threadingContext, - IStreamingFindUsagesPresenter streamingPresenter, - IAsynchronousOperationListenerProvider listenerProvider, - IGlobalOptionService globalOptions) : AbstractGoOrFindNavigationService( - threadingContext, - streamingPresenter, - listenerProvider.GetListener(FeatureAttribute.FindReferences), - globalOptions) -{ - public override string DisplayName => EditorFeaturesResources.Find_References; - - protected override FunctionId FunctionId => FunctionId.CommandHandler_FindAllReference; - - /// - /// For find-refs, we *always* use the window. Even if there is only a single result. This is not a 'go' command - /// which imperatively tries to navigate to the location if possible. The intent here is to keep the results in view - /// so that the user can always refer to them, even as they do other work. - /// - protected override bool NavigateToSingleResultIfQuick => false; - - protected override StreamingFindUsagesPresenterOptions GetStreamingPresenterOptions(Document document) - => new() - { - SupportsReferences = true, - IncludeContainingTypeAndMemberColumns = document.Project.SupportsCompilation, - IncludeKindColumn = document.Project.Language != LanguageNames.FSharp - }; - - protected override Task FindActionAsync(IFindUsagesContext context, Document document, IFindUsagesService service, int caretPosition, CancellationToken cancellationToken) - => service.FindReferencesAsync(context, document, caretPosition, this.ClassificationOptionsProvider, cancellationToken); -} - [Export(typeof(ICommandHandler))] [ContentType(ContentTypeNames.RoslynContentType)] [Name(PredefinedCommandHandlerNames.FindReferences)] diff --git a/src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesNavigationService.cs b/src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesNavigationService.cs new file mode 100644 index 0000000000000..c1de755a242a7 --- /dev/null +++ b/src/EditorFeatures/Core/GoOrFind/FindReferences/FindReferencesNavigationService.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.ComponentModel.Composition; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Editor.Host; +using Microsoft.CodeAnalysis.Editor.Shared.Utilities; +using Microsoft.CodeAnalysis.FindUsages; +using Microsoft.CodeAnalysis.GoOrFind; +using Microsoft.CodeAnalysis.Internal.Log; +using Microsoft.CodeAnalysis.Options; +using Microsoft.CodeAnalysis.Shared.TestHooks; + +namespace Microsoft.CodeAnalysis.FindReferences; + +[Export(typeof(FindReferencesNavigationService))] +[method: ImportingConstructor] +[method: SuppressMessage("RoslynDiagnosticsReliability", "RS0033:Importing constructor should be [Obsolete]", Justification = "Used in test code: https://github.com/dotnet/roslyn/issues/42814")] +internal sealed class FindReferencesNavigationService( + IThreadingContext threadingContext, + IStreamingFindUsagesPresenter streamingPresenter, + IAsynchronousOperationListenerProvider listenerProvider, + IGlobalOptionService globalOptions) : AbstractGoOrFindNavigationService( + threadingContext, + streamingPresenter, + listenerProvider.GetListener(FeatureAttribute.FindReferences), + globalOptions) +{ + public override string DisplayName => EditorFeaturesResources.Find_References; + + protected override FunctionId FunctionId => FunctionId.CommandHandler_FindAllReference; + + /// + /// For find-refs, we *always* use the window. Even if there is only a single result. This is not a 'go' command + /// which imperatively tries to navigate to the location if possible. The intent here is to keep the results in view + /// so that the user can always refer to them, even as they do other work. + /// + protected override bool NavigateToSingleResultIfQuick => false; + + protected override StreamingFindUsagesPresenterOptions GetStreamingPresenterOptions(Document document) + => new() + { + SupportsReferences = true, + IncludeContainingTypeAndMemberColumns = document.Project.SupportsCompilation, + IncludeKindColumn = document.Project.Language != LanguageNames.FSharp + }; + + protected override Task FindActionAsync(IFindUsagesContext context, Document document, IFindUsagesService service, int caretPosition, CancellationToken cancellationToken) + => service.FindReferencesAsync(context, document, caretPosition, this.ClassificationOptionsProvider, cancellationToken); +} diff --git a/src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseCommandHandler.cs b/src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseCommandHandler.cs index a554bc93852cf..f0ac697638c47 100644 --- a/src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseCommandHandler.cs +++ b/src/EditorFeatures/Core/GoOrFind/GoToBase/GoToBaseCommandHandler.cs @@ -19,4 +19,4 @@ namespace Microsoft.CodeAnalysis.GoToBase; [method: ImportingConstructor] [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] internal sealed class GoToBaseCommandHandler(GoToBaseNavigationService navigationService) - : AbstractGoOrFindCommandHandler(navigationService); + : AbstractGoOrFindCommandHandler(navigationService); diff --git a/src/EditorFeatures/Test/FindReferences/FindReferencesCommandHandlerTests.cs b/src/EditorFeatures/Test/FindReferences/FindReferencesCommandHandlerTests.cs index 10fd150e9c13f..3a6a31da9eb4c 100644 --- a/src/EditorFeatures/Test/FindReferences/FindReferencesCommandHandlerTests.cs +++ b/src/EditorFeatures/Test/FindReferences/FindReferencesCommandHandlerTests.cs @@ -66,10 +66,11 @@ public async Task TestFindReferencesAsynchronousCall() var listenerProvider = workspace.ExportProvider.GetExportedValue(); var handler = new FindReferencesCommandHandler( - workspace.ExportProvider.GetExportedValue(), - presenter, - listenerProvider, - workspace.GlobalOptions); + new FindReferencesNavigationService( + workspace.ExportProvider.GetExportedValue(), + presenter, + listenerProvider, + workspace.GlobalOptions)); var textView = workspace.Documents[0].GetTextView(); textView.Caret.MoveTo(new SnapshotPoint(textView.TextSnapshot, 7)); diff --git a/src/EditorFeatures/Test2/FindReferences/FindReferencesCommandHandlerTests.vb b/src/EditorFeatures/Test2/FindReferences/FindReferencesCommandHandlerTests.vb index d93a05d909845..d48285d64141e 100644 --- a/src/EditorFeatures/Test2/FindReferences/FindReferencesCommandHandlerTests.vb +++ b/src/EditorFeatures/Test2/FindReferences/FindReferencesCommandHandlerTests.vb @@ -41,10 +41,11 @@ class C Dim context = New FindUsagesTestContext() Dim commandHandler = New FindReferencesCommandHandler( - workspace.ExportProvider.GetExportedValue(Of IThreadingContext)(), - New MockStreamingFindReferencesPresenter(context), - listenerProvider, - workspace.GlobalOptions) + New FindReferencesNavigationService( + workspace.ExportProvider.GetExportedValue(Of IThreadingContext)(), + New MockStreamingFindReferencesPresenter(context), + listenerProvider, + workspace.GlobalOptions)) Dim document = workspace.CurrentSolution.GetDocument(testDocument.Id) commandHandler.ExecuteCommand( diff --git a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/EditorInProcess.cs b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/EditorInProcess.cs index f41aae591ed0c..aa1e6c3957241 100644 --- a/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/EditorInProcess.cs +++ b/src/VisualStudio/IntegrationTest/New.IntegrationTests/InProcess/EditorInProcess.cs @@ -5,8 +5,8 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.ComponentModel.Design; using System.ComponentModel; +using System.ComponentModel.Design; using System.Linq; using System.Runtime.InteropServices; using System.Text; @@ -1017,11 +1017,11 @@ public async Task ConfigureAsyncNavigation(AsyncNavigationKind kind, Cancellatio }; var componentModelService = await GetRequiredGlobalServiceAsync(cancellationToken); - var commandHandlers = componentModelService.DefaultExportProvider.GetExports(); - var goToImplementation = (GoToImplementationCommandHandler)commandHandlers.Single(handler => handler.Metadata.Name == PredefinedCommandHandlerNames.GoToImplementation).Value; + + var goToImplementation = componentModelService.DefaultExportProvider.GetExportedValue(); goToImplementation.GetTestAccessor().DelayHook = delayHook; - var goToBase = (GoToBaseCommandHandler)commandHandlers.Single(handler => handler.Metadata.Name == PredefinedCommandHandlerNames.GoToBase).Value; + var goToBase = componentModelService.DefaultExportProvider.GetExportedValue(); goToBase.GetTestAccessor().DelayHook = delayHook; } From 962a1879877456ae782980e8c1fc45c0bab9fb20 Mon Sep 17 00:00:00 2001 From: Cyrus Najmabadi Date: Thu, 29 May 2025 16:10:54 +0200 Subject: [PATCH 6/8] Revert --- .../Core/GoOrFind/AbstractGoOrFindNavigationService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs index 05427e949484e..6a159fd91997e 100644 --- a/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs +++ b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs @@ -35,7 +35,7 @@ internal abstract class AbstractGoOrFindNavigationService( : IGoOrFindNavigationService where TLanguageService : class, ILanguageService { - public readonly IThreadingContext ThreadingContext = threadingContext; + private readonly IThreadingContext _threadingContext = threadingContext; private readonly IStreamingFindUsagesPresenter _streamingPresenter = streamingPresenter; private readonly IAsynchronousOperationListener _listener = listener; @@ -86,7 +86,7 @@ public bool IsAvailable([NotNullWhen(true)] Document? document) public bool ExecuteCommand(Document document, int position) { - ThreadingContext.ThrowIfNotOnUIThread(); + _threadingContext.ThrowIfNotOnUIThread(); if (document is null) return false; @@ -113,7 +113,7 @@ private async Task ExecuteCommandAsync( // and failure ourselves. try { - ThreadingContext.ThrowIfNotOnUIThread(); + _threadingContext.ThrowIfNotOnUIThread(); // Make an tracking token so that integration tests can wait until we're complete. using var token = _listener.BeginAsyncOperation($"{GetType().Name}.{nameof(ExecuteCommandAsync)}"); @@ -176,7 +176,7 @@ private async Task ExecuteCommandWorkerAsync( { var title = await findContext.GetSearchTitleAsync(cancellationToken).ConfigureAwait(false); await _streamingPresenter.TryPresentLocationOrNavigateIfOneAsync( - ThreadingContext, + _threadingContext, document.Project.Solution.Workspace, title ?? DisplayName, definitions, @@ -214,7 +214,7 @@ private async Task PresentResultsInStreamingPresenterAsync( Task findTask, CancellationToken cancellationToken) { - await ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); + await _threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); var (presenterContext, presenterCancellationToken) = _streamingPresenter.StartSearch(DisplayName, GetStreamingPresenterOptions(document)); try From 79b6987fb1d3428bdc904c455279a7b24e0a62dc Mon Sep 17 00:00:00 2001 From: Cyrus Najmabadi Date: Thu, 29 May 2025 11:40:22 -0700 Subject: [PATCH 7/8] Speeling --- .../Core/GoOrFind/AbstractGoOrFindNavigationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs index 6a159fd91997e..03367eb99b410 100644 --- a/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs +++ b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs @@ -198,7 +198,7 @@ private Task DelayAsync(CancellationToken cancellationToken) return delayHook(cancellationToken); } - // If we want to navigate to a single result if it is found quickly, then delay showing the find-refs winfor + // If we want to navigate to a single result if it is found quickly, then delay showing the find-refs window // for 1.5 seconds to see if a result comes in by then. If we're not navigating and are always showing the // far window, then don't have any delay showing the window. var delay = this.NavigateToSingleResultIfQuick From 2330fffbf1ce9b46ab7a4941d28680303cb6a515 Mon Sep 17 00:00:00 2001 From: Cyrus Najmabadi Date: Thu, 29 May 2025 22:30:35 +0200 Subject: [PATCH 8/8] REstore cts --- .../AbstractGoOrFindNavigationService.cs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs index 03367eb99b410..236cd54e51eca 100644 --- a/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs +++ b/src/EditorFeatures/Core/GoOrFind/AbstractGoOrFindNavigationService.cs @@ -60,7 +60,7 @@ internal abstract class AbstractGoOrFindNavigationService( /// the presenter. In that case, the presenter will notify us that it has be re-purposed and we will also cancel /// this source. /// - private readonly CancellationSeries _cancellationSeries = new(threadingContext.DisposalToken); + private CancellationTokenSource _cancellationTokenSource = new(); /// /// This hook allows for stabilizing the asynchronous nature of this command handler for integration testing. @@ -95,11 +95,12 @@ public bool ExecuteCommand(Document document, int position) return false; // cancel any prior find-refs that might be in progress. - var cancellationToken = _cancellationSeries.CreateNext(); + _cancellationTokenSource.Cancel(); + _cancellationTokenSource = new(); // we're going to return immediately from ExecuteCommand and kick off our own async work to invoke the // operation. Once this returns, the editor will close the threaded wait dialog it created. - _inProgressCommand = ExecuteCommandAsync(document, service, position, cancellationToken); + _inProgressCommand = ExecuteCommandAsync(document, service, position, _cancellationTokenSource); return true; } @@ -107,7 +108,7 @@ private async Task ExecuteCommandAsync( Document document, TLanguageService service, int position, - CancellationToken cancellationToken) + CancellationTokenSource cancellationTokenSource) { // This is a fire-and-forget method (nothing guarantees observing it). As such, we have to handle cancellation // and failure ourselves. @@ -126,7 +127,7 @@ private async Task ExecuteCommandAsync( // any failures from it. Technically this should not be possible as it should be inside this same // try/catch. however this code wants to be very resilient to any prior mistakes infecting later operations. await _inProgressCommand.NoThrowAwaitable(captureContext: false); - await ExecuteCommandWorkerAsync(document, service, position, cancellationToken).ConfigureAwait(false); + await ExecuteCommandWorkerAsync(document, service, position, cancellationTokenSource).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -140,7 +141,7 @@ private async Task ExecuteCommandWorkerAsync( Document document, TLanguageService service, int position, - CancellationToken cancellationToken) + CancellationTokenSource cancellationTokenSource) { // Switch to the BG immediately so we can keep as much work off the UI thread. await TaskScheduler.Default; @@ -158,6 +159,7 @@ private async Task ExecuteCommandWorkerAsync( // IStreamingFindUsagesPresenter. var findContext = new BufferedFindUsagesContext(); + var cancellationToken = cancellationTokenSource.Token; var delayBeforeShowingResultsWindowTask = DelayAsync(cancellationToken); var findTask = FindResultsAsync(findContext, document, service, position, cancellationToken); @@ -188,7 +190,7 @@ await _streamingPresenter.TryPresentLocationOrNavigateIfOneAsync( // We either got no results, or 1.5 has passed and we didn't figure out the symbols to navigate to or // present. So pop up the presenter to show the user that we're involved in a longer search, without // blocking them. - await PresentResultsInStreamingPresenterAsync(document, findContext, findTask, cancellationToken).ConfigureAwait(false); + await PresentResultsInStreamingPresenterAsync(document, findContext, findTask, cancellationTokenSource).ConfigureAwait(false); } private Task DelayAsync(CancellationToken cancellationToken) @@ -212,8 +214,9 @@ private async Task PresentResultsInStreamingPresenterAsync( Document document, BufferedFindUsagesContext findContext, Task findTask, - CancellationToken cancellationToken) + CancellationTokenSource cancellationTokenSource) { + var cancellationToken = cancellationTokenSource.Token; await _threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken); var (presenterContext, presenterCancellationToken) = _streamingPresenter.StartSearch(DisplayName, GetStreamingPresenterOptions(document)); @@ -229,7 +232,7 @@ private async Task PresentResultsInStreamingPresenterAsync( // Hook up the presenter's cancellation token to our overall governing cancellation token. In other // words, if something else decides to present in the presenter (like a find-refs call) we'll hear about // that and can cancel all our work. - presenterCancellationToken.Register(() => _cancellationSeries.CreateNext()); + presenterCancellationToken.Register(() => cancellationTokenSource.Cancel()); // now actually wait for the find work to be done. await findTask.ConfigureAwait(false);