File: DocumentationComments\DocumentationCommentSuggestion.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;
using System.ComponentModel;
using System.Drawing;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Editor.Copilot;
using Microsoft.CodeAnalysis.Internal.Log;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Microsoft.VisualStudio.Language.Proposals;
using Microsoft.VisualStudio.Language.Suggestions;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Threading;
 
namespace Microsoft.CodeAnalysis.DocumentationComments;
 
internal sealed class DocumentationCommentSuggestion(CopilotGenerateDocumentationCommentProvider providerInstance,
    SuggestionManagerBase suggestionManager, VisualStudio.Threading.IAsyncDisposable? intelliCodeLineCompletionsDisposable) : SuggestionBase
{
    public SuggestionManagerBase SuggestionManager { get; } = suggestionManager;
 
    public VisualStudio.Threading.IAsyncDisposable? IntelliCodeLineCompletionsDisposable { get; set; } = intelliCodeLineCompletionsDisposable;
 
    public override TipStyle TipStyle => TipStyle.AlwaysShowTip | CopilotConstants.ShowThinkingStateTipStyle;
 
    public override EditDisplayStyle EditStyle => EditDisplayStyle.GrayText;
 
    public override bool HasMultipleSuggestions => false;
 
    public override event PropertyChangedEventHandler PropertyChanged { add { } remove { } }
 
    private SuggestionSessionBase? _suggestionSession;
 
    public override async Task OnAcceptedAsync(SuggestionSessionBase session, ProposalBase originalProposal, ProposalBase currentProposal, ReasonForAccept reason, CancellationToken cancel)
    {
        var threadingContext = providerInstance.ThreadingContext;
 
        await threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancel);
        await DisposeIntelliCodeCompletionsDisposableAsync().ConfigureAwait(false);
        Logger.Log(FunctionId.Copilot_Generate_Documentation_Accepted, logLevel: LogLevel.Information);
    }
 
    public override Task OnChangeProposalAsync(SuggestionSessionBase session, ProposalBase originalProposal, ProposalBase currentProposal, bool forward, CancellationToken cancel)
    {
        return Task.CompletedTask;
    }
 
    public override async Task OnDismissedAsync(SuggestionSessionBase session, ProposalBase? originalProposal, ProposalBase? currentProposal, ReasonForDismiss reason, CancellationToken cancel)
    {
        var threadingContext = providerInstance.ThreadingContext;
        await threadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancel);
        await ClearSuggestionAsync(reason, cancel).ConfigureAwait(false);
        Logger.Log(FunctionId.Copilot_Generate_Documentation_Dismissed, logLevel: LogLevel.Information);
    }
 
    public override Task OnProposalUpdatedAsync(SuggestionSessionBase session, ProposalBase? originalProposal, ProposalBase? currentProposal, ReasonForUpdate reason, VirtualSnapshotPoint caret, CompletionState? completionState, CancellationToken cancel)
    {
        if (reason.HasFlag(ReasonForUpdate.Diverged))
        {
            Logger.Log(FunctionId.Copilot_Generate_Documentation_Diverged, logLevel: LogLevel.Information);
            return session.DismissAsync(ReasonForDismiss.DismissedAfterBufferChange, cancel);
        }
 
        return Task.CompletedTask;
    }
 
    public async Task StartSuggestionSessionWithProposalAsync(
        Func<CancellationToken, Task<ProposalBase?>> generateProposal, CancellationToken cancellationToken)
    {
        var sessionStarted = await StartSuggestionSessionAsync(cancellationToken).ConfigureAwait(false);
        if (!sessionStarted)
        {
            return;
        }
 
        var proposal = await generateProposal(cancellationToken).ConfigureAwait(false);
        if (proposal is null)
        {
            await DismissSuggestionSessionAsync(cancellationToken).ConfigureAwait(false);
            return;
        }
 
        await TryDisplayDocumentationSuggestionAsync(proposal, cancellationToken).ConfigureAwait(false);
    }
 
    /// <summary>
    /// Starts the Suggestion Session. The TryDisplaySuggestion call doesn't display any grey text, but starts the session such that we have the
    /// exclusive right to display grey text later.
    /// </summary>
    /// <returns>If true, user will see the thinking state as long as the Suggestion Session is active and replace with grey text if a call to DisplayProposal succeeds.
    /// If unable to retrieve the session, the caller should bail out.
    /// </returns>
    private async Task<bool> StartSuggestionSessionAsync(CancellationToken cancellationToken)
    {
        _suggestionSession = await RunWithEnqueueActionAsync(
            "StartWork",
            async () => await SuggestionManager.TryDisplaySuggestionAsync(this, cancellationToken).ConfigureAwait(false),
        cancellationToken).ConfigureAwait(false);
 
        if (_suggestionSession is null)
        {
            await DisposeIntelliCodeCompletionsDisposableAsync().ConfigureAwait(false);
            return false;
        }
 
        return true;
    }
 
    /// <summary>
    /// This is where we actually try to display the grey-text from the proposal
    /// we created.
    /// </summary>
    public async Task TryDisplayDocumentationSuggestionAsync(ProposalBase proposal, CancellationToken cancellationToken)
    {
        try
        {
            await RunWithEnqueueActionAsync<bool>(
                "DisplayProposal",
                async () =>
                {
                    await _suggestionSession!.DisplayProposalAsync(proposal, cancellationToken).ConfigureAwait(false);
                    return true;
                },
                cancellationToken).ConfigureAwait(false);
 
            Logger.Log(FunctionId.Copilot_Generate_Documentation_Displayed, logLevel: LogLevel.Information);
        }
        catch (OperationCanceledException)
        {
            Logger.Log(FunctionId.Copilot_Generate_Documentation_Canceled, logLevel: LogLevel.Information);
        }
    }
 
    /// <summary>
    /// Dismisses the session if the proposal we generated was invalid.
    /// Needs to dispose of the IntelliCodeCompletionsDisposable so we no longer have exclusive right to
    /// display any grey text.
    /// </summary>
    private async Task DismissSuggestionSessionAsync(CancellationToken cancellationToken)
    {
        await RunWithEnqueueActionAsync<bool>(
            "DismissSuggestionSession",
            async () =>
            {
                await ClearSuggestionAsync(ReasonForDismiss.DismissedDueToInvalidProposal, cancellationToken).ConfigureAwait(false);
                return true;
            },
            cancellationToken).ConfigureAwait(false);
    }
 
    /// <summary>
    /// In general, calls to a SuggestionManager or SuggestionSession need to be wrapped in an EnqueueAction.
    /// This is the pattern recommended by VS Platform to avoid races.
    /// Pattern from platform shown here:
    /// https://devdiv.visualstudio.com/DevDiv/_git/IntelliCode-VS?path=/src/VSIX/IntelliCode.VSIX/SuggestionService/AmbientAI/SuggestionProviderForAmbientAI.cs
    /// </summary>
    private async Task<T> RunWithEnqueueActionAsync<T>(string description, Func<Task<T>> action, CancellationToken cancellationToken)
    {
        Assumes.NotNull(SuggestionManager);
 
        var taskCompletionSource = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
 
        await providerInstance.ThreadingContext.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
        SuggestionManager.EnqueueAction(description, async () =>
        {
            try
            {
                var result = await action().ConfigureAwaitRunInline();
                taskCompletionSource.TrySetResult(result);
            }
            catch (OperationCanceledException operationCanceledException)
            {
                taskCompletionSource.TrySetCanceled(operationCanceledException.CancellationToken);
            }
            catch (Exception exception)
            {
                taskCompletionSource.TrySetException(exception);
            }
        });
 
        return await taskCompletionSource.Task.WithCancellation(cancellationToken).ConfigureAwait(false);
    }
 
    private async Task ClearSuggestionAsync(ReasonForDismiss reason, CancellationToken cancellationToken)
    {
        if (_suggestionSession != null)
        {
            await _suggestionSession.DismissAsync(reason, cancellationToken).ConfigureAwait(false);
        }
 
        _suggestionSession = null;
        await DisposeIntelliCodeCompletionsDisposableAsync().ConfigureAwait(false);
    }
 
    /// <summary>
    /// The IntelliCodeLineCompletionDisposable needs to be disposed any time we exit the SuggestionSession so that
    /// line completions can be shown again.
    /// </summary>
    private async Task DisposeIntelliCodeCompletionsDisposableAsync()
    {
        if (IntelliCodeLineCompletionsDisposable != null)
        {
            await IntelliCodeLineCompletionsDisposable.DisposeAsync().ConfigureAwait(false);
            IntelliCodeLineCompletionsDisposable = null;
        }
    }
}