File: Suggestions\SuggestedActions\EditorSuggestedActionWithNestedFlavors.cs
Web Access
Project: src\src\EditorFeatures\Core\Microsoft.CodeAnalysis.EditorFeatures.csproj (Microsoft.CodeAnalysis.EditorFeatures)
// 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.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.Copilot;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Editor.Host;
using Microsoft.CodeAnalysis.Editor.Shared.Extensions;
using Microsoft.CodeAnalysis.Editor.Shared.Utilities;
using Microsoft.CodeAnalysis.Extensions;
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.VisualStudio.Language.Intellisense;
using Microsoft.VisualStudio.Text;
 
namespace Microsoft.CodeAnalysis.Editor.Implementation.Suggestions;
 
/// <summary>
/// Type for all SuggestedActions that have 'flavors'.  'Flavors' are child actions that are presented as simple links,
/// not as menu-items, in the light-bulb.  Examples of 'flavors' include 'preview changes' (for refactorings and fixes)
/// and 'fix all in document, project, solution' (for refactorings and fixes).
/// 
/// Supports 'preview changes' for all changes.
/// </summary>
internal sealed partial class EditorSuggestedActionWithNestedFlavors(
    IThreadingContext threadingContext,
    SuggestedActionsSourceProvider sourceProvider,
    TextDocument originalDocument,
    ITextBuffer subjectBuffer,
    object provider,
    CodeAction codeAction,
    SuggestedActionSet? fixAllFlavors,
    ImmutableArray<Diagnostic> diagnostics)
    : EditorSuggestedAction(threadingContext,
        sourceProvider,
        originalDocument.Project.Solution,
        subjectBuffer,
        provider,
        codeAction), ISuggestedActionWithFlavors, ITelemetryDiagnosticID<string?>
{
    private readonly SuggestedActionSet? _fixAllFlavors = fixAllFlavors;
    private readonly ImmutableArray<Diagnostic> _diagnostics = diagnostics;
 
    private ImmutableArray<SuggestedActionSet> _nestedFlavors;
 
    public TextDocument OriginalDocument { get; } = originalDocument;
 
    /// <summary>
    /// HasActionSets is always true because we always know we provide 'preview changes'.
    /// </summary>
    public sealed override bool HasActionSets => true;
 
    public sealed override async Task<IEnumerable<SuggestedActionSet>?> GetActionSetsAsync(CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        // Light bulb will always invoke this property on the UI thread.
        this.ThreadingContext.ThrowIfNotOnUIThread();
 
        if (_nestedFlavors.IsDefault)
        {
            var extensionManager = this.OriginalSolution.Services.GetRequiredService<IExtensionManager>();
 
            // Note: We must ensure that CreateAllFlavorsAsync does not perform any expensive
            // long running operations as it will be invoked when a lightbulb preview is brought
            // up for any code action. Currently, the only async method call within CreateAllFlavorsAsync
            // is made within 'RefineUsingCopilotSuggestedAction.TryCreateAsync', which needs to
            // check if Copilot service is available using a relatively cheap, but async method call.
            _nestedFlavors = await extensionManager.PerformFunctionAsync(
                Provider, CreateAllFlavorsAsync,
                defaultValue: [], cancellationToken).ConfigureAwait(false);
        }
 
        Contract.ThrowIfTrue(_nestedFlavors.IsDefault);
        return _nestedFlavors;
    }
 
    private async Task<ImmutableArray<SuggestedActionSet>> CreateAllFlavorsAsync(CancellationToken cancellationToken)
    {
        using var _ = ArrayBuilder<SuggestedActionSet>.GetInstance(out var builder);
 
        builder.Add(await GetPrimarySuggestedActionSetAsync(cancellationToken).ConfigureAwait(false));
        builder.AddIfNotNull(_fixAllFlavors);
 
        return builder.ToImmutableAndClear();
    }
 
    private async Task<SuggestedActionSet> GetPrimarySuggestedActionSetAsync(CancellationToken cancellationToken)
    {
        // In this method we add all the primary flavored suggested actions that need to show up
        // as hyperlinks on the lightbulb preview pane for all code actions.
        //  - We always add the 'Preview Changes' suggested action.
        //  - We add the 'Refine using Copilot' suggested action, if certain conditions are met. See comments
        //    inside 'RefineUsingCopilotSuggestedAction.TryCreateAsync' for details.
        //  - We add the custom suggested actions corresponding to the additional flavored actions defined
        //    by the underlying code action.
        // Note that flavored suggested actions for Fix All operations are added in a separate
        // suggested action set by our caller, we don't add them here.
 
        using var _ = ArrayBuilder<EditorSuggestedAction>.GetInstance(out var suggestedActions);
        var previewChangesAction = CreateTrivialAction(
            this, new PreviewChangesCodeAction(this.CodeAction, this.GetPreviewResultAsync));
        suggestedActions.Add(previewChangesAction);
 
        var refineUsingCopilotAction = await TryCreateRefineSuggestedActionAsync().ConfigureAwait(false);
        if (refineUsingCopilotAction != null)
            suggestedActions.Add(refineUsingCopilotAction);
 
        foreach (var action in this.CodeAction.AdditionalPreviewFlavors)
            suggestedActions.Add(CreateTrivialAction(this, action));
 
        return new SuggestedActionSet(categoryName: null, actions: suggestedActions.ToImmutable());
 
        async Task<EditorSuggestedAction?> TryCreateRefineSuggestedActionAsync()
        {
            if (this.OriginalDocument is not Document originalDocument)
                return null;
 
            if (originalDocument.GetLanguageService<ICopilotOptionsService>() is not { } optionsService ||
                 await optionsService.IsRefineOptionEnabledAsync().ConfigureAwait(false) is false)
            {
                return null;
            }
 
            if (originalDocument.GetLanguageService<ICopilotCodeAnalysisService>() is not { } copilotService ||
                await copilotService.IsAvailableAsync(cancellationToken).ConfigureAwait(false) is false)
            {
                return null;
            }
 
            return CreateTrivialAction(
                this, new RefineUsingCopilotCodeAction(
                    this.OriginalSolution, this.CodeAction, _diagnostics.FirstOrDefault(), copilotService));
        }
    }
 
    // HasPreview is called synchronously on the UI thread. In order to avoid blocking the UI thread,
    // we need to provide a 'quick' answer here as opposed to the 'right' answer. Providing the 'right'
    // answer is expensive (because we will need to call CodeAction.GetPreviewOperationsAsync() for this
    // and this will involve computing the changed solution for the ApplyChangesOperation for the fix /
    // refactoring). So we always return 'true' here (so that platform will call GetActionSetsAsync()
    // below). Platform guarantees that nothing bad will happen if we return 'true' here and later return
    // 'null' / empty collection from within GetPreviewAsync().
    public override bool HasPreview => true;
 
    public override async Task<object?> GetPreviewAsync(CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
 
        // Light bulb will always invoke this function on the UI thread.
        this.ThreadingContext.ThrowIfNotOnUIThread();
 
        var previewPaneService = this.OriginalSolution.Services.GetService<IPreviewPaneService>();
        if (previewPaneService == null)
        {
            return null;
        }
 
        // after this point, this method should only return at GetPreviewPane. otherwise, DifferenceViewer will leak
        // since there is no one to close the viewer
        var preferredDocumentId = this.OriginalDocument.Id;
        var preferredProjectId = preferredDocumentId.ProjectId;
 
        var extensionManager = this.OriginalSolution.Services.GetRequiredService<IExtensionManager>();
        var previewContents = await extensionManager.PerformFunctionAsync(Provider, async cancellationToken =>
        {
            // We need to stay on UI thread after GetPreviewResultAsync() so that TakeNextPreviewAsync()
            // below can execute on UI thread. We use ConfigureAwait(true) to stay on the UI thread.
            var previewResult = await GetPreviewResultAsync(cancellationToken).ConfigureAwait(true);
            if (previewResult == null)
            {
                return null;
            }
            else
            {
                // TakeNextPreviewAsync() needs to run on UI thread.
                this.ThreadingContext.ThrowIfNotOnUIThread();
                return await previewResult.GetPreviewsAsync(preferredDocumentId, preferredProjectId, cancellationToken).ConfigureAwait(true);
            }
 
            // GetPreviewPane() below needs to run on UI thread. We use ConfigureAwait(true) to stay on the UI thread.
        }, defaultValue: null, cancellationToken).ConfigureAwait(true);
 
        // GetPreviewPane() needs to run on the UI thread.
        this.ThreadingContext.ThrowIfNotOnUIThread();
 
        var diagnosticData = DiagnosticData.Create(_diagnostics.FirstOrDefault(), this.OriginalDocument.Project);
        return previewPaneService.GetPreviewPane(diagnosticData, previewContents!);
    }
 
    public string? GetDiagnosticID()
        => _diagnostics.FirstOrDefault()?.GetTelemetryDiagnosticID();
}