File: NestedFiles\NestedFileCommandHandler.cs
Web Access
Project: src\src\Razor\src\Razor\src\Microsoft.VisualStudio.RazorExtension\Microsoft.VisualStudio.RazorExtension_r1ze3jzg_wpftmp.csproj (Microsoft.VisualStudio.RazorExtension)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Razor.Extensions;
using Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.AspNetCore.Razor.Utilities;
using Microsoft.CodeAnalysis.Razor.NestedFiles;
using Microsoft.CodeAnalysis.Razor.Protocol.NestedFiles;
using Microsoft.VisualStudio.Razor.LanguageClient;
using Microsoft.VisualStudio.Razor.ProjectSystem;
using Microsoft.VisualStudio.Shell;
 
namespace Microsoft.VisualStudio.RazorExtension.NestedFiles;
 
/// <summary>
/// Handles Add/View nested file commands for Razor documents from both Solution Explorer
/// and editor context menus.
/// When the file doesn't exist, sends an LSP request to the Razor language server
/// to create it via workspace/applyEdit. When the file exists, just opens it.
/// </summary>
/// <param name="serviceProvider">VS service provider for accessing shell services.</param>
/// <param name="fileExtension">The nested file extension to add/view (e.g., ".cs", ".css", ".js").</param>
/// <param name="fileKind">The kind of nested file, used when creating new files via LSP.</param>
/// <param name="requestInvoker">Lazy wrapper for sending LSP requests to the Razor language server.</param>
/// <param name="allowExternalHandlers">
/// When true, sets Supported = false instead of Visible = false when the command doesn't apply. 
/// This tells VS that this handler does not own the command, allowing external handlers (e.g., the default 
/// ViewCode/F7 handler) to take over.
/// </param>
/// <param name="hideWhenFileExists">
/// When true, hides the command when the nested file already exists. Used for the editor-only
/// "Add .cs" command (cmdidAddNestedCsFileEditor), which is hidden when the .cs file exists
/// because cmdidViewCode with F7 handles the "View" case instead.
/// </param>
internal sealed class NestedFileCommandHandler(
    IServiceProvider serviceProvider,
    string fileExtension,
    NestedFileKind fileKind,
    Lazy<LSPRequestInvokerWrapper> requestInvoker,
    bool allowExternalHandlers,
    bool hideWhenFileExists)
{
    private readonly IServiceProvider _serviceProvider = serviceProvider;
    private readonly string _fileExtension = fileExtension;
    private readonly NestedFileKind _fileKind = fileKind;
    private readonly Lazy<LSPRequestInvokerWrapper> _requestInvoker = requestInvoker;
    private readonly bool _allowExternalHandlers = allowExternalHandlers;
    private readonly bool _hideWhenFileExists = hideWhenFileExists;
 
    /// <summary>
    /// Configures the command status and text based on whether the nested file exists.
    /// </summary>
    public void OnBeforeQueryStatus(object sender, EventArgs e)
    {
        if (sender is not OleMenuCommand command)
        {
            return;
        }
 
        // Check if the Razor file context is active before doing expensive hierarchy queries
        if (!SelectionHelper.IsRazorFileUIContextActive(_serviceProvider)
            || GetSelectedRazorFilePath() is not string razorFilePath)
        {
            if (_allowExternalHandlers)
            {
                command.Supported = false;
            }
            else
            {
                command.Visible = false;
            }
 
            return;
        }
 
        var nestedFilePath = GetNestedFilePath(razorFilePath);
        var nestedFileExists = File.Exists(nestedFilePath);
 
        if (_allowExternalHandlers && !nestedFileExists)
        {
            // yield so another handler can show "Add" (without F7 keybinding)
            command.Supported = false;
            return;
        }
 
        if (_hideWhenFileExists && nestedFileExists)
        {
            // The nested file exists and we've been told this command should be hidden in that case
            command.Visible = false;
            return;
        }
 
        var nestedFileName = Path.GetFileName(nestedFilePath);
 
        command.Supported = true;
        command.Visible = true;
        command.Enabled = true;
        command.Text = nestedFileExists ? Resources.FormatView_Nested_File(nestedFileName) : Resources.FormatAdd_Nested_File(nestedFileName);
    }
 
    /// <summary>
    /// Executes the command - either opens an existing nested file or creates a new one
    /// via the LSP server and then opens it.
    /// </summary>
    public void Execute(object sender, EventArgs e)
    {
        ThreadHelper.ThrowIfNotOnUIThread();
 
        if (GetSelectedRazorFilePath() is not string razorFilePath)
        {
            return;
        }
 
        var nestedFilePath = GetNestedFilePath(razorFilePath);
 
        if (File.Exists(nestedFilePath))
        {
            // View: just open the existing file
            VsShellUtilities.OpenDocument(_serviceProvider, nestedFilePath);
        }
        else
        {
            // Add: send LSP request to create the file, then open it.
            // FileAndForget ensures exceptions are reported to telemetry rather than silently swallowed.
#pragma warning disable VSSDK007 // Fire-and-forget from synchronous EventHandler is intentional
#pragma warning disable RS0030 // NestedFileCommandHandler does not currently flow IThreadingContext.
            ThreadHelper.JoinableTaskFactory.RunAsync(
                () => CreateAndOpenNestedFileAsync(razorFilePath, nestedFilePath, CancellationToken.None)).FileAndForget("NestedFileCommandHandler.Execute");
#pragma warning restore RS0030
#pragma warning restore VSSDK007
        }
    }
 
    private async Task CreateAndOpenNestedFileAsync(
        string razorFilePath,
        string nestedFilePath,
        CancellationToken cancellationToken)
    {
        // The cohost endpoint will create the file via workspace/applyEdit.
        // By the time this returns, the file should exist on disk.
        await _requestInvoker.Value.ReinvokeRequestOnServerAsync<AddNestedFileParams, object?>(
            RazorLSPConstants.AddNestedFileName,
            RazorLSPConstants.RoslynLanguageServerName,
            AddNestedFileParams.Create(new Uri(razorFilePath), _fileKind),
            cancellationToken);
 
        if (File.Exists(nestedFilePath))
        {
#pragma warning disable RS0030 // NestedFileCommandHandler does not currently flow IThreadingContext.
            await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
#pragma warning restore RS0030
 
            // The workspace/applyEdit creates the file and inserts content via TextDocumentEdit,
            // which leaves the buffer dirty. Save it so the user sees a clean document.
            VsShellUtilities.SaveFileIfDirty(_serviceProvider, nestedFilePath);
            VsShellUtilities.OpenDocument(_serviceProvider, nestedFilePath);
        }
    }
 
    /// <summary>
    /// Gets the path to the nested file based on the Razor file path.
    /// </summary>
    private string GetNestedFilePath(string razorFilePath)
    {
        Debug.Assert(_fileExtension.StartsWith('.'));
        return razorFilePath + _fileExtension;
    }
 
    /// <summary>
    /// Gets the file path of the currently selected/active Razor file.
    /// This works for both Solution Explorer selection and the active editor document,
    /// because IVsMonitorSelection tracks the active window frame's hierarchy item.
    /// </summary>
    private string? GetSelectedRazorFilePath()
    {
        var filePath = SelectionHelper.GetCurrentSelectionPath(_serviceProvider);
 
        if (filePath is not null
            && FileUtilities.IsAnyRazorFilePath(filePath, StringComparison.OrdinalIgnoreCase)
            && Path.GetFileName(filePath) is string fileName
            && !string.Equals(fileName, ComponentHelpers.ImportsFileName, StringComparison.OrdinalIgnoreCase)
            && !string.Equals(fileName, MvcImportProjectFeature.ImportsFileName, StringComparison.OrdinalIgnoreCase))
        {
            return filePath;
        }
 
        return null;
    }
}