diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToNewComponentCodeActionParams.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToNewComponentCodeActionParams.cs new file mode 100644 index 00000000000..9c30b64aeed --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Models/ExtractToNewComponentCodeActionParams.cs @@ -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; } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionResolver.cs index 4fc7b9fd4eb..82558438ee4 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToCodeBehindCodeActionResolver.cs @@ -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("/") @@ -134,33 +134,6 @@ internal sealed class ExtractToCodeBehindCodeActionResolver( }; } - /// - /// Generate a file path with adjacent to our input path that has the - /// correct codebehind extension, using numbers to differentiate from - /// any collisions. - /// - /// The origin file path. - /// A non-existent file path with the same base name and a codebehind extension. - 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 GenerateCodeBehindClassAsync(IProjectSnapshot project, Uri codeBehindUri, string className, string namespaceName, string contents, RazorCodeDocument razorCodeDocument, CancellationToken cancellationToken) { using var _ = StringBuilderPool.GetPooledObject(out var builder); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionProvider.cs new file mode 100644 index 00000000000..6c3b3e8ee38 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionProvider.cs @@ -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(); + + public Task> ProvideAsync(RazorCodeActionContext context, CancellationToken cancellationToken) + { + if (context is null) + { + return SpecializedTasks.EmptyImmutableArray(); + } + + if (!context.SupportsFileCreation) + { + return SpecializedTasks.EmptyImmutableArray(); + } + + if (!FileKinds.IsComponent(context.CodeDocument.GetFileKind())) + { + return SpecializedTasks.EmptyImmutableArray(); + } + + var syntaxTree = context.CodeDocument.GetSyntaxTree(); + if (syntaxTree?.Root is null) + { + return SpecializedTasks.EmptyImmutableArray(); + } + + var owner = syntaxTree.Root.FindInnermostNode(context.Location.AbsoluteIndex, includeWhitespace: true); + if (owner is null) + { + _logger.LogWarning($"Owner should never be null."); + return SpecializedTasks.EmptyImmutableArray(); + } + + var componentNode = owner.FirstAncestorOrSelf(); + + // Make sure we've found tag + if (componentNode is null) + { + return SpecializedTasks.EmptyImmutableArray(); + } + + // 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(); + } + + if (!TryGetNamespace(context.CodeDocument, out var @namespace)) + { + return SpecializedTasks.EmptyImmutableArray(); + } + + 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>([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); +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionResolver.cs new file mode 100644 index 00000000000..8b176aeb891 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ExtractToNewComponentCodeActionResolver.cs @@ -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 ResolveAsync(JsonElement data, CancellationToken cancellationToken) + { + if (data.ValueKind == JsonValueKind.Undefined) + { + return null; + } + + var actionParams = JsonSerializer.Deserialize(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[] + { + 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, + }; + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/RazorCodeActionFactory.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/RazorCodeActionFactory.cs index 832f3c2a351..6a7d697c1ca 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/RazorCodeActionFactory.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/RazorCodeActionFactory.cs @@ -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"); @@ -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 diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs index 167a0c55101..59707cbcd56 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs @@ -146,6 +146,8 @@ public static void AddCodeActionsServices(this IServiceCollection services) // Razor Code actions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/SR.resx b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/SR.resx index 1fb4ef212c8..b94c06607a4 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/SR.resx +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/SR.resx @@ -183,4 +183,7 @@ statement + + Extract element to new component + \ No newline at end of file diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.cs.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.cs.xlf index 20bb58692c9..e9bd2e979f3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.cs.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.cs.xlf @@ -47,6 +47,11 @@ Extrahovat blok do kódu na pozadí + + Extract element to new component + Extract element to new component + + File was externally modified: {0} Došlo k externí úpravě souboru: {0} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.de.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.de.xlf index c886ab68c2f..94402ecb4de 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.de.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.de.xlf @@ -47,6 +47,11 @@ Block auf CodeBehind extrahieren + + Extract element to new component + Extract element to new component + + File was externally modified: {0} Datei wurde extern modifiziert: {0}. diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.es.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.es.xlf index 7b2b4b26587..3e5b8b11c1e 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.es.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.es.xlf @@ -47,6 +47,11 @@ Extraer el bloque al código subyacente + + Extract element to new component + Extract element to new component + + File was externally modified: {0} El archivo se modificó externamente: {0} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.fr.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.fr.xlf index d6ecf5e41ae..4808f1c26a4 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.fr.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.fr.xlf @@ -47,6 +47,11 @@ Extraire le bloc vers le code-behind + + Extract element to new component + Extract element to new component + + File was externally modified: {0} Le fichier a été modifié en externe : {0} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.it.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.it.xlf index 32f81ab0c1c..33470714554 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.it.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.it.xlf @@ -47,6 +47,11 @@ Estrai il blocco in code-behind + + Extract element to new component + Extract element to new component + + File was externally modified: {0} Il file è stato modificato esternamente: {0} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ja.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ja.xlf index 08fba968925..fd8f4765763 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ja.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ja.xlf @@ -47,6 +47,11 @@ ブロック抽出から分離コード + + Extract element to new component + Extract element to new component + + File was externally modified: {0} ファイルが外部で変更されました: {0} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ko.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ko.xlf index 2642eb6486d..62d14e8d091 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ko.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ko.xlf @@ -47,6 +47,11 @@ 코드 숨김에 블록 추출 + + Extract element to new component + Extract element to new component + + File was externally modified: {0} {0}의 파일이 외부에서 수정되었습니다. diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.pl.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.pl.xlf index fc746b124d2..b5ca44e0fb3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.pl.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.pl.xlf @@ -47,6 +47,11 @@ Wyodrębnij blok do kodu znajdującego się poza + + Extract element to new component + Extract element to new component + + File was externally modified: {0} Plik został zmodyfikowany na zewnątrz: {0} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.pt-BR.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.pt-BR.xlf index 9380bdda8f2..023270c89c0 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.pt-BR.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.pt-BR.xlf @@ -47,6 +47,11 @@ Extrair o bloco para codificar atrás + + Extract element to new component + Extract element to new component + + File was externally modified: {0} O arquivo foi modificado externamente: {0} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ru.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ru.xlf index 3cd2089390d..9b4b541b350 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ru.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.ru.xlf @@ -47,6 +47,11 @@ Извлечь блок в код программной части + + Extract element to new component + Extract element to new component + + File was externally modified: {0} Файл был изменен извне: {0} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.tr.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.tr.xlf index 350cc2a946a..211840b046c 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.tr.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.tr.xlf @@ -47,6 +47,11 @@ Bloğu arkadaki koda ayıkla + + Extract element to new component + Extract element to new component + + File was externally modified: {0} Dosya dışarıdan değiştirildi: {0} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.zh-Hans.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.zh-Hans.xlf index 02e7c59af60..c746d3ecc08 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.zh-Hans.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.zh-Hans.xlf @@ -47,6 +47,11 @@ 将块提取到代码隐藏中 + + Extract element to new component + Extract element to new component + + File was externally modified: {0} 已从外部修改了文件: {0}。 diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.zh-Hant.xlf b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.zh-Hant.xlf index c283c24de51..ddd96b48565 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.zh-Hant.xlf +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Resources/xlf/SR.zh-Hant.xlf @@ -47,6 +47,11 @@ 擷取區塊以在後方編碼 + + Extract element to new component + Extract element to new component + + File was externally modified: {0} 已在外部修改檔案: {0} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs index be2fb1feec3..cdd0ce37190 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/LanguageServerConstants.cs @@ -37,6 +37,8 @@ public static class CodeActions public const string ExtractToCodeBehindAction = "ExtractToCodeBehind"; + public const string ExtractToNewComponentAction = "ExtractToNewComponent"; + public const string CreateComponentFromTag = "CreateComponentFromTag"; public const string AddUsing = "AddUsing"; diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToNewComponentCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToNewComponentCodeActionProviderTest.cs new file mode 100644 index 00000000000..120a93192a6 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/Razor/ExtractToNewComponentCodeActionProviderTest.cs @@ -0,0 +1,101 @@ +// 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.Linq; +using System.Text; +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.LanguageServer.CodeActions; +using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor; +using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Razor.Protocol.CodeActions; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.Test.CodeActions.Razor; + +public class ExtractToNewComponentCodeActionProviderTest(ITestOutputHelper testOutput) : LanguageServerTestBase(testOutput) +{ + [Fact (Skip = "Not fully set up yet")] + public async Task Handle_InvalidFileKind() + { + // Arrange + var documentPath = "c:/Test.razor"; + var contents = """ + @page "/test" + + This is my title! + This is my paragraph! + Click me + + This is my other paragraph! + + + + @$$code {} + """; + TestFileMarkupParser.GetPosition(contents, out contents, out var cursorPosition); + + var request = new VSCodeActionParams() + { + TextDocument = new VSTextDocumentIdentifier { Uri = new Uri(documentPath) }, + Range = new Range(), + Context = new VSInternalCodeActionContext() + }; + + var location = new SourceLocation(cursorPosition, -1, -1); + var context = CreateRazorCodeActionContext(request, location, documentPath, contents); + context.CodeDocument.SetFileKind(FileKinds.Legacy); + + var provider = new ExtractToNewComponentCodeActionProvider(LoggerFactory); + + // Act + var commandOrCodeActionContainer = await provider.ProvideAsync(context, default); + + // Assert + Assert.Empty(commandOrCodeActionContainer); + } + + private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionParams request, SourceLocation location, string filePath, string text, bool supportsFileCreation = true) + => CreateRazorCodeActionContext(request, location, filePath, text, relativePath: filePath, supportsFileCreation: supportsFileCreation); + + private static RazorCodeActionContext CreateRazorCodeActionContext(VSCodeActionParams request, SourceLocation location, string filePath, string text, string? relativePath, bool supportsFileCreation = true) + { + var sourceDocument = RazorSourceDocument.Create(text, RazorSourceDocumentProperties.Create(filePath, relativePath)); + var options = RazorParserOptions.Create(o => + { + o.Directives.Add(ComponentCodeDirective.Directive); + o.Directives.Add(FunctionsDirective.Directive); + }); + var syntaxTree = RazorSyntaxTree.Parse(sourceDocument, options); + + var codeDocument = TestRazorCodeDocument.Create(sourceDocument, imports: default); + codeDocument.SetFileKind(FileKinds.Component); + codeDocument.SetCodeGenerationOptions(RazorCodeGenerationOptions.Create(o => + { + o.RootNamespace = "ExtractToCodeBehindTest"; + })); + codeDocument.SetSyntaxTree(syntaxTree); + + var documentSnapshot = Mock.Of(document => + document.GetGeneratedOutputAsync() == Task.FromResult(codeDocument) && + document.GetTextAsync() == Task.FromResult(codeDocument.GetSourceText()), MockBehavior.Strict); + + var sourceText = SourceText.From(text); + + var context = new RazorCodeActionContext(request, documentSnapshot, codeDocument, location, sourceText, supportsFileCreation, SupportsCodeActionResolve: true); + + return context; + } +} diff --git a/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/FileUtilities.cs b/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/FileUtilities.cs new file mode 100644 index 00000000000..c93a6e35fd2 --- /dev/null +++ b/src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/FileUtilities.cs @@ -0,0 +1,43 @@ +// 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.Globalization; +using System.IO; + +namespace Microsoft.AspNetCore.Razor.Utilities; + +internal static class FileUtilities +{ + /// + /// Generate a file path adjacent to the input path that has the + /// specified file extension, using numbers to differentiate for + /// any collisions. + /// + /// The input file path. + /// The input file extension with a prepended ".". + /// A non-existent file path with a name in the specified format and a corresponding extension. + public static string GenerateUniquePath(string path, string extension) + { + if (!Path.IsPathRooted(path)) + { + throw new ArgumentException("The path is not rooted.", nameof(path)); + } + + var directoryName = Path.GetDirectoryName(path).AssumeNotNull(); + var baseFileName = Path.GetFileNameWithoutExtension(path); + + var n = 0; + string uniquePath; + do + { + var identifier = n > 0 ? n.ToString(CultureInfo.InvariantCulture) : string.Empty; // Make it look nice + + uniquePath = Path.Combine(directoryName, $"{baseFileName}{identifier}{extension}"); + n++; + } + while (File.Exists(uniquePath)); + + return uniquePath; + } +}
This is my paragraph!
This is my other paragraph!