Skip to content

Commit

Permalink
Added basic extract to component functionality on cursor over html tag (
Browse files Browse the repository at this point in the history
#10578)

### Summary of the changes

Part of the implementation of the _Extract To Component_ code action.
Functional in one of the two cases, when the user is not selecting a
certain range of a Razor component, but rather when the cursor is on
either the opening or closing tag.
  • Loading branch information
phil-allen-msft committed Jul 24, 2024
2 parents 921aef1 + 371b09a commit 8725265
Show file tree
Hide file tree
Showing 23 changed files with 492 additions and 28 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Text.Json.Serialization;

namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models;

internal sealed class ExtractToNewComponentCodeActionParams
{
[JsonPropertyName("uri")]
public required Uri Uri { get; set; }
[JsonPropertyName("extractStart")]
public int ExtractStart { get; set; }
[JsonPropertyName("extractEnd")]
public int ExtractEnd { get; set; }
[JsonPropertyName("namespace")]
public required string Namespace { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ internal sealed class ExtractToCodeBehindCodeActionResolver(
return null;
}

var codeBehindPath = GenerateCodeBehindPath(path);
var codeBehindPath = FileUtilities.GenerateUniquePath(path, $"{Path.GetExtension(path)}.cs");

// VS Code in Windows expects path to start with '/'
var updatedCodeBehindPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !codeBehindPath.StartsWith("/")
Expand Down Expand Up @@ -134,33 +134,6 @@ internal sealed class ExtractToCodeBehindCodeActionResolver(
};
}

/// <summary>
/// Generate a file path with adjacent to our input path that has the
/// correct codebehind extension, using numbers to differentiate from
/// any collisions.
/// </summary>
/// <param name="path">The origin file path.</param>
/// <returns>A non-existent file path with the same base name and a codebehind extension.</returns>
private static string GenerateCodeBehindPath(string path)
{
var baseFileName = Path.GetFileNameWithoutExtension(path);
var extension = Path.GetExtension(path);
var directoryName = Path.GetDirectoryName(path).AssumeNotNull();

var n = 0;
string codeBehindPath;
do
{
var identifier = n > 0 ? n.ToString(CultureInfo.InvariantCulture) : string.Empty; // Make it look nice

codeBehindPath = Path.Combine(directoryName, $"{baseFileName}{identifier}{extension}.cs");
n++;
}
while (File.Exists(codeBehindPath));

return codeBehindPath;
}

private async Task<string> GenerateCodeBehindClassAsync(IProjectSnapshot project, Uri codeBehindUri, string className, string namespaceName, string contents, RazorCodeDocument razorCodeDocument, CancellationToken cancellationToken)
{
using var _ = StringBuilderPool.GetPooledObject(out var builder);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.AspNetCore.Razor.Language.Extensions;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models;
using Microsoft.AspNetCore.Razor.Threading;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Workspaces;

namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor;

internal sealed class ExtractToNewComponentCodeActionProvider(ILoggerFactory loggerFactory) : IRazorCodeActionProvider
{
private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<ExtractToNewComponentCodeActionProvider>();

public Task<ImmutableArray<RazorVSInternalCodeAction>> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken)
{
if (context is null)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

if (!context.SupportsFileCreation)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

if (!FileKinds.IsComponent(context.CodeDocument.GetFileKind()))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var syntaxTree = context.CodeDocument.GetSyntaxTree();
if (syntaxTree?.Root is null)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true);
if (owner is null)
{
_logger.LogWarning($"Owner should never be null.");
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var componentNode = owner.FirstAncestorOrSelf<MarkupElementSyntax>();

// Make sure we've found tag
if (componentNode is null)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

// Do not provide code action if the cursor is inside proper html content (i.e. page text)
if (context.Location.AbsoluteIndex > componentNode.StartTag.Span.End &&
context.Location.AbsoluteIndex < componentNode.EndTag.SpanStart)
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

if (!TryGetNamespace(context.CodeDocument, out var @namespace))
{
return SpecializedTasks.EmptyImmutableArray<RazorVSInternalCodeAction>();
}

var actionParams = new ExtractToNewComponentCodeActionParams()
{
Uri = context.Request.TextDocument.Uri,
ExtractStart = componentNode.Span.Start,
ExtractEnd = componentNode.Span.End,
Namespace = @namespace
};

var resolutionParams = new RazorCodeActionResolutionParams()
{
Action = LanguageServerConstants.CodeActions.ExtractToNewComponentAction,
Language = LanguageServerConstants.CodeActions.Languages.Razor,
Data = actionParams,
};

var codeAction = RazorCodeActionFactory.CreateExtractToNewComponent(resolutionParams);

return Task.FromResult<ImmutableArray<RazorVSInternalCodeAction>>([codeAction]);
}

private static bool TryGetNamespace(RazorCodeDocument codeDocument, [NotNullWhen(returnValue: true)] out string? @namespace)
// If the compiler can't provide a computed namespace it will fallback to "__GeneratedComponent" or
// similar for the NamespaceNode. This would end up with extracting to a wrong namespace
// and causing compiler errors. Avoid offering this refactoring if we can't accurately get a
// good namespace to extract to
=> codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out @namespace);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models;
using Microsoft.AspNetCore.Razor.LanguageServer.Hosting;
using Microsoft.AspNetCore.Razor.Utilities;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using Newtonsoft.Json.Linq;

namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor;

internal sealed class ExtractToNewComponentCodeActionResolver(
IDocumentContextFactory documentContextFactory,
LanguageServerFeatureOptions languageServerFeatureOptions) : IRazorCodeActionResolver
{

private readonly IDocumentContextFactory _documentContextFactory = documentContextFactory;
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;

public string Action => LanguageServerConstants.CodeActions.ExtractToNewComponentAction;

public async Task<WorkspaceEdit?> ResolveAsync(JsonElement data, CancellationToken cancellationToken)
{
if (data.ValueKind == JsonValueKind.Undefined)
{
return null;
}

var actionParams = JsonSerializer.Deserialize<ExtractToNewComponentCodeActionParams>(data.GetRawText());
if (actionParams is null)
{
return null;
}

if (!_documentContextFactory.TryCreate(actionParams.Uri, out var documentContext))
{
return null;
}

var componentDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
if (componentDocument.IsUnsupported())
{
return null;
}

if (!FileKinds.IsComponent(componentDocument.GetFileKind()))
{
return null;
}

var path = FilePathNormalizer.Normalize(actionParams.Uri.GetAbsoluteOrUNCPath());
var directoryName = Path.GetDirectoryName(path).AssumeNotNull();
var templatePath = Path.Combine(directoryName, "Component");
var componentPath = FileUtilities.GenerateUniquePath(templatePath, ".razor");

// VS Code in Windows expects path to start with '/'
var updatedComponentPath = _languageServerFeatureOptions.ReturnCodeActionAndRenamePathsWithPrefixedSlash && !componentPath.StartsWith('/')
? '/' + componentPath
: componentPath;

var newComponentUri = new UriBuilder
{
Scheme = Uri.UriSchemeFile,
Path = updatedComponentPath,
Host = string.Empty,
}.Uri;

var text = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);
if (text is null)
{
return null;
}

var componentName = Path.GetFileNameWithoutExtension(componentPath);
var newComponentContent = text.GetSubTextString(new TextSpan(actionParams.ExtractStart, actionParams.ExtractEnd - actionParams.ExtractStart)).Trim();

var start = componentDocument.Source.Text.Lines.GetLinePosition(actionParams.ExtractStart);
var end = componentDocument.Source.Text.Lines.GetLinePosition(actionParams.ExtractEnd);
var removeRange = new Range
{
Start = new Position(start.Line, start.Character),
End = new Position(end.Line, end.Character)
};

var componentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = actionParams.Uri };
var newComponentDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier { Uri = newComponentUri };

var documentChanges = new SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>[]
{
new CreateFile { Uri = newComponentUri },
new TextDocumentEdit
{
TextDocument = componentDocumentIdentifier,
Edits = new[]
{
new TextEdit
{
NewText = $"<{componentName} />",
Range = removeRange,
}
},
},
new TextDocumentEdit
{
TextDocument = newComponentDocumentIdentifier,
Edits = new[]
{
new TextEdit
{
NewText = newComponentContent,
Range = new Range { Start = new Position(0, 0), End = new Position(0, 0) },
}
},
}
};

return new WorkspaceEdit
{
DocumentChanges = documentChanges,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal static class RazorCodeActionFactory
private readonly static Guid s_fullyQualifyComponentTelemetryId = new("3d9abe36-7d10-4e08-8c18-ad88baa9a923");
private readonly static Guid s_createComponentFromTagTelemetryId = new("a28e0baa-a4d5-4953-a817-1db586035841");
private readonly static Guid s_createExtractToCodeBehindTelemetryId = new("f63167f7-fdc6-450f-8b7b-b240892f4a27");
private readonly static Guid s_createExtractToNewComponentTelemetryId = new("af67b0a3-f84b-4808-97a7-b53e85b22c64");
private readonly static Guid s_generateMethodTelemetryId = new("c14fa003-c752-45fc-bb29-3a123ae5ecef");
private readonly static Guid s_generateAsyncMethodTelemetryId = new("9058ca47-98e2-4f11-bf7c-a16a444dd939");

Expand Down Expand Up @@ -67,6 +68,19 @@ public static RazorVSInternalCodeAction CreateExtractToCodeBehind(RazorCodeActio
return codeAction;
}

public static RazorVSInternalCodeAction CreateExtractToNewComponent(RazorCodeActionResolutionParams resolutionParams)
{
var title = SR.ExtractTo_NewComponent_Title;
var data = JsonSerializer.SerializeToElement(resolutionParams);
var codeAction = new RazorVSInternalCodeAction()
{
Title = title,
Data = data,
TelemetryId = s_createExtractToNewComponentTelemetryId,
};
return codeAction;
}

public static RazorVSInternalCodeAction CreateGenerateMethod(Uri uri, string methodName, string eventName)
{
var @params = new GenerateMethodCodeActionParams
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ public static void AddCodeActionsServices(this IServiceCollection services)
// Razor Code actions
services.AddSingleton<IRazorCodeActionProvider, ExtractToCodeBehindCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, ExtractToCodeBehindCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, ExtractToNewComponentCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver ,ExtractToNewComponentCodeActionResolver>();
services.AddSingleton<IRazorCodeActionProvider, ComponentAccessibilityCodeActionProvider>();
services.AddSingleton<IRazorCodeActionResolver, CreateComponentCodeActionResolver>();
services.AddSingleton<IRazorCodeActionResolver, AddUsingsCodeActionResolver>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,7 @@
<data name="Statement" xml:space="preserve">
<value>statement</value>
</data>
<data name="ExtractTo_NewComponent_Title" xml:space="preserve">
<value>Extract element to new component</value>
</data>
</root>

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 8725265

Please sign in to comment.