diff --git a/extensions/typescript-language-features/src/languageFeatures/refactor.ts b/extensions/typescript-language-features/src/languageFeatures/refactor.ts index 3ecfcbd63d3..c51b883af54 100644 --- a/extensions/typescript-language-features/src/languageFeatures/refactor.ts +++ b/extensions/typescript-language-features/src/languageFeatures/refactor.ts @@ -3,24 +3,52 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; import * as vscode from 'vscode'; +import { Utils } from 'vscode-uri'; import { Command, CommandManager } from '../commands/commandManager'; import { LearnMoreAboutRefactoringsCommand } from '../commands/learnMoreAboutRefactorings'; import type * as Proto from '../protocol'; import { ClientCapability, ITypeScriptServiceClient } from '../typescriptService'; import API from '../utils/api'; +import { coalesce } from '../utils/arrays'; import { nulToken } from '../utils/cancellation'; import { conditionalRegistration, requireSomeCapability } from '../utils/dependentRegistration'; import { DocumentSelector } from '../utils/documentSelector'; import * as fileSchemes from '../utils/fileSchemes'; +import { Schemes } from '../utils/schemes'; import { TelemetryReporter } from '../utils/telemetry'; import * as typeConverters from '../utils/typeConverters'; import FormattingOptionsManager from './fileConfigurationManager'; +function toWorkspaceEdit(client: ITypeScriptServiceClient, edits: readonly Proto.FileCodeEdits[]): vscode.WorkspaceEdit { + const workspaceEdit = new vscode.WorkspaceEdit(); + for (const edit of edits) { + const resource = client.toResource(edit.fileName); + if (resource.scheme === fileSchemes.file) { + workspaceEdit.createFile(resource, { ignoreIfExists: true }); + } + } + typeConverters.WorkspaceEdit.withFileCodeEdits(workspaceEdit, client, edits); + return workspaceEdit; +} -interface DidApplyRefactoringCommand_Args { - readonly codeAction: InlinedCodeAction; +class CompositeCommand implements Command { + public static readonly ID = '_typescript.compositeCommand'; + public readonly id = CompositeCommand.ID; + + public async execute(...commands: vscode.Command[]): Promise { + for (const command of commands) { + await vscode.commands.executeCommand(command.command, ...(command.arguments ?? [])); + } + } +} + +namespace DidApplyRefactoringCommand { + export interface Args { + readonly action: string; + } } class DidApplyRefactoringCommand implements Command { @@ -31,7 +59,7 @@ class DidApplyRefactoringCommand implements Command { private readonly telemetryReporter: TelemetryReporter ) { } - public async execute(args: DidApplyRefactoringCommand_Args): Promise { + public async execute(args: DidApplyRefactoringCommand.Args): Promise { /* __GDPR__ "refactor.execute" : { "owner": "mjbvz", @@ -42,32 +70,16 @@ class DidApplyRefactoringCommand implements Command { } */ this.telemetryReporter.logTelemetry('refactor.execute', { - action: args.codeAction.action, + action: args.action, }); - - if (!args.codeAction.edit?.size) { - vscode.window.showErrorMessage(vscode.l10n.t("Could not apply refactoring")); - return; - } - - const renameLocation = args.codeAction.renameLocation; - if (renameLocation) { - // Disable renames in interactive playground https://github.com/microsoft/vscode/issues/75137 - if (args.codeAction.document.uri.scheme !== fileSchemes.walkThroughSnippet) { - await vscode.commands.executeCommand('editor.action.rename', [ - args.codeAction.document.uri, - typeConverters.Position.fromLocation(renameLocation) - ]); - } - } } } - -interface SelectRefactorCommand_Args { - readonly action: vscode.CodeAction; - readonly document: vscode.TextDocument; - readonly info: Proto.ApplicableRefactorInfo; - readonly rangeOrSelection: vscode.Range | vscode.Selection; +namespace SelectRefactorCommand { + export interface Args { + readonly document: vscode.TextDocument; + readonly refactor: Proto.ApplicableRefactorInfo; + readonly rangeOrSelection: vscode.Range | vscode.Selection; + } } class SelectRefactorCommand implements Command { @@ -76,16 +88,16 @@ class SelectRefactorCommand implements Command { constructor( private readonly client: ITypeScriptServiceClient, - private readonly didApplyCommand: DidApplyRefactoringCommand ) { } - public async execute(args: SelectRefactorCommand_Args): Promise { + public async execute(args: SelectRefactorCommand.Args): Promise { const file = this.client.toOpenTsFilePath(args.document); if (!file) { return; } - const selected = await vscode.window.showQuickPick(args.info.actions.map((action): vscode.QuickPickItem => ({ + const selected = await vscode.window.showQuickPick(args.refactor.actions.map((action): vscode.QuickPickItem & { action: Proto.RefactorActionInfo } => ({ + action, label: action.name, description: action.description, }))); @@ -93,7 +105,7 @@ class SelectRefactorCommand implements Command { return; } - const tsAction = new InlinedCodeAction(this.client, args.action.title, args.action.kind, args.document, args.info.name, selected.label, args.rangeOrSelection); + const tsAction = new InlinedCodeAction(this.client, args.document, args.refactor, selected.action, args.rangeOrSelection); await tsAction.resolve(nulToken); if (tsAction.edit) { @@ -103,7 +115,120 @@ class SelectRefactorCommand implements Command { } } - await this.didApplyCommand.execute({ codeAction: tsAction }); + if (tsAction.command) { + await vscode.commands.executeCommand(tsAction.command.command, ...(tsAction.command.arguments ?? [])); + } + } +} + +namespace MoveToFileRefactorCommand { + export interface Args { + readonly document: vscode.TextDocument; + readonly action: Proto.RefactorActionInfo; + readonly range: vscode.Range; + } +} + +class MoveToFileRefactorCommand implements Command { + public static readonly ID = '_typescript.moveToFileRefactoring'; + public readonly id = MoveToFileRefactorCommand.ID; + + constructor( + private readonly client: ITypeScriptServiceClient, + private readonly didApplyCommand: DidApplyRefactoringCommand + ) { } + + public async execute(args: MoveToFileRefactorCommand.Args): Promise { + const file = this.client.toOpenTsFilePath(args.document); + if (!file) { + return; + } + + const targetFile = await this.getTargetFile(args.document, file, args.range); + if (!targetFile) { + return; + } + + const fileSuggestionArgs: Proto.GetEditsForMoveToFileRefactorRequestArgs = { + ...typeConverters.Range.toFileRangeRequestArgs(file, args.range), + filepath: targetFile, + action: 'Move to file', + refactor: 'Move to file', + }; + + const response = await this.client.execute('getEditsForMoveToFileRefactor', fileSuggestionArgs, nulToken); + if (response.type !== 'response' || !response.body) { + return; + } + const edit = toWorkspaceEdit(this.client, response.body.edits); + if (!(await vscode.workspace.applyEdit(edit))) { + vscode.window.showErrorMessage(vscode.l10n.t("Could not apply refactoring")); + return; + } + + await this.didApplyCommand.execute({ action: args.action.name }); + } + + private async getTargetFile(document: vscode.TextDocument, file: string, range: vscode.Range): Promise { + const args = typeConverters.Range.toFileRangeRequestArgs(file, range); + const response = await this.client.execute('getMoveToRefactoringFileSuggestions', args, nulToken); + if (response.type !== 'response' || !response.body) { + return; + } + + const selectFileItem: vscode.QuickPickItem = { + label: vscode.l10n.t("Select file..."), + detail: vscode.l10n.t("Select file or enter new file path..."), + }; + + type DestinationItem = vscode.QuickPickItem & { file: string }; + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + + const destinationItems = response.body.files.map((file): DestinationItem => { + const uri = this.client.toResource(file); + const parentDir = Utils.dirname(uri); + + let description; + if (workspaceFolder) { + if (uri.scheme === Schemes.file) { + description = path.relative(workspaceFolder.uri.fsPath, parentDir.fsPath); + } else { + description = path.posix.relative(workspaceFolder.uri.path, parentDir.path); + } + } else { + description = parentDir.fsPath; + } + + return { + file, + label: Utils.basename(uri), + description, + }; + }); + + const picked = await vscode.window.showQuickPick([ + selectFileItem, + { label: vscode.l10n.t("Destination Files"), kind: vscode.QuickPickItemKind.Separator }, + ...destinationItems + ], { + title: vscode.l10n.t("Move to File"), + placeHolder: vscode.l10n.t("Enter file path"), + }); + if (!picked) { + return; + } + + if (picked === selectFileItem) { + const picked = await vscode.window.showSaveDialog({ + title: vscode.l10n.t("Select move destination"), + saveLabel: vscode.l10n.t("Move to File"), + defaultUri: vscode.Uri.joinPath(Utils.dirname(document.uri), response.body.newFilename) + }); + return picked ? this.client.toTsFilePath(picked) : undefined; + } + + return (picked as DestinationItem).file; } } @@ -132,6 +257,11 @@ const Extract_Interface = Object.freeze({ matches: refactor => refactor.name.startsWith('Extract to interface') }); +const Move_File = Object.freeze({ + kind: vscode.CodeActionKind.RefactorMove.append('file'), + matches: refactor => refactor.name.startsWith('Move to file') +}); + const Move_NewFile = Object.freeze({ kind: vscode.CodeActionKind.RefactorMove.append('newFile'), matches: refactor => refactor.name.startsWith('Move to a new file') @@ -167,6 +297,7 @@ const allKnownCodeActionKinds = [ Extract_Constant, Extract_Type, Extract_Interface, + Move_File, Move_NewFile, Rewrite_Import, Rewrite_Export, @@ -178,18 +309,23 @@ const allKnownCodeActionKinds = [ class InlinedCodeAction extends vscode.CodeAction { constructor( public readonly client: ITypeScriptServiceClient, - title: string, - kind: vscode.CodeActionKind | undefined, public readonly document: vscode.TextDocument, - public readonly refactor: string, - public readonly action: string, + public readonly refactor: Proto.ApplicableRefactorInfo, + public readonly action: Proto.RefactorActionInfo, public readonly range: vscode.Range, ) { - super(title, kind); - } + super(action.description, InlinedCodeAction.getKind(action)); - // Filled in during resolve - public renameLocation?: Proto.Location; + if (action.notApplicableReason) { + this.disabled = { reason: action.notApplicableReason }; + } + + this.command = { + title: action.description, + command: DidApplyRefactoringCommand.ID, + arguments: [{ action: action.name }], + }; + } public async resolve(token: vscode.CancellationToken): Promise { const file = this.client.toOpenTsFilePath(this.document); @@ -199,8 +335,8 @@ class InlinedCodeAction extends vscode.CodeAction { const args: Proto.GetEditsForRefactorRequestArgs = { ...typeConverters.Range.toFileRangeRequestArgs(file, this.range), - refactor: this.refactor, - action: this.action, + refactor: this.refactor.name, + action: this.action.name, }; const response = await this.client.execute('getEditsForRefactor', args, token); @@ -208,26 +344,59 @@ class InlinedCodeAction extends vscode.CodeAction { return; } - // Resolve - this.edit = InlinedCodeAction.getWorkspaceEditForRefactoring(this.client, response.body); - this.renameLocation = response.body.renameLocation; + this.edit = toWorkspaceEdit(this.client, response.body.edits); + if (!this.edit.size) { + vscode.window.showErrorMessage(vscode.l10n.t("Could not apply refactoring")); + return; + } - return; - } - - private static getWorkspaceEditForRefactoring( - client: ITypeScriptServiceClient, - body: Proto.RefactorEditInfo, - ): vscode.WorkspaceEdit { - const workspaceEdit = new vscode.WorkspaceEdit(); - for (const edit of body.edits) { - const resource = client.toResource(edit.fileName); - if (resource.scheme === fileSchemes.file) { - workspaceEdit.createFile(resource, { ignoreIfExists: true }); + if (response.body.renameLocation) { + // Disable renames in interactive playground https://github.com/microsoft/vscode/issues/75137 + if (this.document.uri.scheme !== fileSchemes.walkThroughSnippet) { + this.command = { + command: CompositeCommand.ID, + title: '', + arguments: coalesce([ + this.command, + { + command: 'editor.action.rename', + arguments: [[ + this.document.uri, + typeConverters.Position.fromLocation(response.body.renameLocation) + ]] + } + ]) + }; } } - typeConverters.WorkspaceEdit.withFileCodeEdits(workspaceEdit, client, body.edits); - return workspaceEdit; + } + + private static getKind(refactor: Proto.RefactorActionInfo) { + if ((refactor as Proto.RefactorActionInfo & { kind?: string }).kind) { + return vscode.CodeActionKind.Empty.append((refactor as Proto.RefactorActionInfo & { kind?: string }).kind!); + } + const match = allKnownCodeActionKinds.find(kind => kind.matches(refactor)); + return match ? match.kind : vscode.CodeActionKind.Refactor; + } +} + +class MoveToFileCodeAction extends vscode.CodeAction { + constructor( + document: vscode.TextDocument, + action: Proto.RefactorActionInfo, + range: vscode.Range, + ) { + super(action.description, Move_File.kind); + + if (action.notApplicableReason) { + this.disabled = { reason: action.notApplicableReason }; + } + + this.command = { + title: action.description, + command: MoveToFileRefactorCommand.ID, + arguments: [{ action, document, range }] + }; } } @@ -241,12 +410,12 @@ class SelectCodeAction extends vscode.CodeAction { this.command = { title: info.description, command: SelectRefactorCommand.ID, - arguments: [{ action: this, document, info, rangeOrSelection }] + arguments: [{ action: this, document, refactor: info, rangeOrSelection }] }; } } -type TsCodeAction = InlinedCodeAction | SelectCodeAction; +type TsCodeAction = InlinedCodeAction | MoveToFileCodeAction | SelectCodeAction; class TypeScriptRefactorProvider implements vscode.CodeActionProvider { @@ -257,7 +426,9 @@ class TypeScriptRefactorProvider implements vscode.CodeActionProvider { + const actions = Array.from(this.convertApplicableRefactors(document, response.body, rangeOrSelection)).filter(action => { if (this.client.apiVersion.lt(API.v430)) { // Don't show 'infer return type' refactoring unless it has been explicitly requested // https://github.com/microsoft/TypeScript/issues/42993 @@ -341,43 +512,34 @@ class TypeScriptRefactorProvider implements vscode.CodeActionProvider { + for (const refactor of refactors) { + if (refactor.inlineable === false) { + yield new SelectCodeAction(refactor, document, rangeOrSelection); } else { - for (const action of info.actions) { - actions.push(this.refactorActionToCodeAction(action, document, info, rangeOrSelection, info.actions)); + for (const action of refactor.actions) { + yield this.refactorActionToCodeAction(document, refactor, action, rangeOrSelection, refactor.actions); } } } - return actions; } private refactorActionToCodeAction( - action: Proto.RefactorActionInfo, document: vscode.TextDocument, - info: Proto.ApplicableRefactorInfo, + refactor: Proto.ApplicableRefactorInfo, + action: Proto.RefactorActionInfo, rangeOrSelection: vscode.Range | vscode.Selection, allActions: readonly Proto.RefactorActionInfo[], - ): InlinedCodeAction { - const codeAction = new InlinedCodeAction(this.client, action.description, TypeScriptRefactorProvider.getKind(action), document, info.name, action.name, rangeOrSelection); - - // https://github.com/microsoft/TypeScript/pull/37871 - if (action.notApplicableReason) { - codeAction.disabled = { reason: action.notApplicableReason }; + ): TsCodeAction { + let codeAction: TsCodeAction; + if (action.name === 'Move to file') { + codeAction = new MoveToFileCodeAction(document, action, rangeOrSelection); } else { - codeAction.command = { - title: action.description, - command: DidApplyRefactoringCommand.ID, - arguments: [{ codeAction }], - }; + codeAction = new InlinedCodeAction(this.client, document, refactor, action, rangeOrSelection); } codeAction.isPreferred = TypeScriptRefactorProvider.isPreferred(action, allActions); @@ -394,14 +556,6 @@ class TypeScriptRefactorProvider implements vscode.CodeActionProvider kind.matches(refactor)); - return match ? match.kind : vscode.CodeActionKind.Refactor; - } - private static isPreferred( action: Proto.RefactorActionInfo, allActions: readonly Proto.RefactorActionInfo[], diff --git a/extensions/typescript-language-features/src/protocol.d.ts b/extensions/typescript-language-features/src/protocol.d.ts index 38345971fc8..0018bc10451 100644 --- a/extensions/typescript-language-features/src/protocol.d.ts +++ b/extensions/typescript-language-features/src/protocol.d.ts @@ -19,5 +19,37 @@ declare module 'typescript/lib/tsserverlibrary' { interface Response { readonly _serverType?: ServerType; } + + interface GetMoveToRefactoringFileSuggestionsRequest extends Request { + command: 'getMoveToRefactoringFileSuggestions'; + arguments: GetMoveToRefactoringFileSuggestionsRequestArgs; + } + + type GetMoveToRefactoringFileSuggestionsRequestArgs = FileLocationOrRangeRequestArgs & { + triggerReason?: RefactorTriggerReason; + kind?: string; + }; + + interface GetMoveToRefactoringFileSuggestionsResponse extends Response { + body?: { + newFilename: string; + files: string[]; + }; + } + + interface GetEditsForMoveToFileRefactorRequest extends Request { + command: 'getEditsForMoveToFileRefactor'; + arguments: GetEditsForMoveToFileRefactorRequestArgs; + } + + interface GetEditsForMoveToFileRefactorResponse extends Response { + body?: RefactorEditInfo; + } + + type GetEditsForMoveToFileRefactorRequestArgs = FileLocationOrRangeRequestArgs & { + refactor: string; + action: string; + filepath: string; + }; } } diff --git a/extensions/typescript-language-features/src/typescriptService.ts b/extensions/typescript-language-features/src/typescriptService.ts index faba971b1d6..50abf93e2fd 100644 --- a/extensions/typescript-language-features/src/typescriptService.ts +++ b/extensions/typescript-language-features/src/typescriptService.ts @@ -74,6 +74,8 @@ interface StandardTsServerRequests { 'provideInlayHints': [Proto.InlayHintsRequestArgs, Proto.InlayHintsResponse]; 'encodedSemanticClassifications-full': [Proto.EncodedSemanticClassificationsRequestArgs, Proto.EncodedSemanticClassificationsResponse]; 'findSourceDefinition': [Proto.FileLocationRequestArgs, Proto.DefinitionResponse]; + 'getMoveToRefactoringFileSuggestions': [Proto.GetMoveToRefactoringFileSuggestionsRequestArgs, Proto.GetMoveToRefactoringFileSuggestionsResponse]; + 'getEditsForMoveToFileRefactor': [Proto.GetEditsForMoveToFileRefactorRequestArgs, Proto.GetEditsForMoveToFileRefactorResponse]; } interface NoResponseTsServerRequests { diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 418a3b54335..fd53def5dd3 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -352,8 +352,15 @@ export class MainThreadLanguageFeatures extends Disposable implements MainThread if (supportsResolve) { provider.resolveCodeAction = async (codeAction: languages.CodeAction, token: CancellationToken): Promise => { - const data = await this._proxy.$resolveCodeAction(handle, (codeAction).cacheId!, token); - codeAction.edit = reviveWorkspaceEditDto(data, this._uriIdentService); + const resolved = await this._proxy.$resolveCodeAction(handle, (codeAction).cacheId!, token); + if (resolved.edit) { + codeAction.edit = reviveWorkspaceEditDto(resolved.edit, this._uriIdentService); + } + + if (resolved.command) { + codeAction.command = resolved.command; + } + return codeAction; }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index b42dbbd1267..78e52da7fc7 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1825,7 +1825,7 @@ export interface ExtHostLanguageFeaturesShape { $provideLinkedEditingRanges(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise; $provideReferences(handle: number, resource: UriComponents, position: IPosition, context: languages.ReferenceContext, token: CancellationToken): Promise; $provideCodeActions(handle: number, resource: UriComponents, rangeOrSelection: IRange | ISelection, context: languages.CodeActionContext, token: CancellationToken): Promise; - $resolveCodeAction(handle: number, id: ChainedCacheId, token: CancellationToken): Promise; + $resolveCodeAction(handle: number, id: ChainedCacheId, token: CancellationToken): Promise<{ edit?: IWorkspaceEditDto; command?: ICommandDto }>; $releaseCodeActions(handle: number, cacheId: number): void; $prepareDocumentPaste(handle: number, uri: UriComponents, ranges: readonly IRange[], dataTransfer: DataTransferDTO, token: CancellationToken): Promise; $providePasteEdits(handle: number, requestId: number, uri: UriComponents, ranges: IRange[], dataTransfer: DataTransferDTO, token: CancellationToken): Promise; diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index 087fb2df70c..86280efe228 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -457,19 +457,33 @@ class CodeActionAdapter { return { cacheId, actions }; } - async resolveCodeAction(id: extHostProtocol.ChainedCacheId, token: CancellationToken): Promise { + async resolveCodeAction(id: extHostProtocol.ChainedCacheId, token: CancellationToken): Promise<{ edit?: extHostProtocol.IWorkspaceEditDto; command?: extHostProtocol.ICommandDto }> { const [sessionId, itemId] = id; const item = this._cache.get(sessionId, itemId); if (!item || CodeActionAdapter._isCommand(item)) { - return undefined; // code actions only! + return {}; // code actions only! } if (!this._provider.resolveCodeAction) { - return; // this should not happen... + return {}; // this should not happen... } + + const resolvedItem = (await this._provider.resolveCodeAction(item, token)) ?? item; - return resolvedItem?.edit - ? typeConvert.WorkspaceEdit.from(resolvedItem.edit, undefined) - : undefined; + + let resolvedEdit: extHostProtocol.IWorkspaceEditDto | undefined; + if (resolvedItem.edit) { + resolvedEdit = typeConvert.WorkspaceEdit.from(resolvedItem.edit, undefined); + } + + let resolvedCommand: extHostProtocol.ICommandDto | undefined; + if (resolvedItem.command) { + const disposables = this._disposables.get(sessionId); + if (disposables) { + resolvedCommand = this._commands.toInternal(resolvedItem.command, disposables); + } + } + + return { edit: resolvedEdit, command: resolvedCommand }; } releaseCodeActions(cachedId: number): void { @@ -2021,8 +2035,8 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF return this._withAdapter(handle, CodeActionAdapter, adapter => adapter.provideCodeActions(URI.revive(resource), rangeOrSelection, context, token), undefined, token); } - $resolveCodeAction(handle: number, id: extHostProtocol.ChainedCacheId, token: CancellationToken): Promise { - return this._withAdapter(handle, CodeActionAdapter, adapter => adapter.resolveCodeAction(id, token), undefined, undefined); + $resolveCodeAction(handle: number, id: extHostProtocol.ChainedCacheId, token: CancellationToken): Promise<{ edit?: extHostProtocol.IWorkspaceEditDto; command?: extHostProtocol.ICommandDto }> { + return this._withAdapter(handle, CodeActionAdapter, adapter => adapter.resolveCodeAction(id, token), {}, undefined); } $releaseCodeActions(handle: number, cacheId: number): void {