Skip to content

Commit

Permalink
Enable renaming of matching jsx tags
Browse files Browse the repository at this point in the history
Fixes microsoft#159534

Uses the new linked editing api to make f2 rename matching jsx tags
  • Loading branch information
mjbvz committed Apr 12, 2023
1 parent ca9404b commit 330113c
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 30 deletions.
12 changes: 12 additions & 0 deletions extensions/typescript-language-features/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,18 @@
"description": "%typescript.preferences.useAliasesForRenames%",
"scope": "language-overridable"
},
"javascript.preferences.renameMatchingJsxTags": {
"type": "boolean",
"default": true,
"description": "%typescript.preferences.renameMatchingJsxTags%",
"scope": "language-overridable"
},
"typescript.preferences.renameMatchingJsxTags": {
"type": "boolean",
"default": true,
"description": "%typescript.preferences.renameMatchingJsxTags%",
"scope": "language-overridable"
},
"typescript.updateImportsOnFileMove.enabled": {
"type": "string",
"enum": [
Expand Down
1 change: 1 addition & 0 deletions extensions/typescript-language-features/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
"configuration.tsserver.watchOptions.synchronousWatchDirectory": "Disable deferred watching on directories. Deferred watching is useful when lots of file changes might occur at once (e.g. a change in node_modules from running npm install), but you might want to disable it with this flag for some less-common setups.",
"typescript.preferences.renameShorthandProperties.deprecationMessage": "The setting 'typescript.preferences.renameShorthandProperties' has been deprecated in favor of 'typescript.preferences.useAliasesForRenames'",
"typescript.preferences.useAliasesForRenames": "Enable/disable introducing aliases for object shorthand properties during renames.",
"typescript.preferences.renameMatchingJsxTags": "When on a JSX tag, try to rename the matching tag instead of renaming the symbol. Requires using TypeScript 5.1+ in the workspace.",
"typescript.workspaceSymbols.scope": "Controls which files are searched by [Go to Symbol in Workspace](https://code.visualstudio.com/docs/editor/editingevolved#_open-symbol-by-name).",
"typescript.workspaceSymbols.scope.allOpenProjects": "Search all open JavaScript or TypeScript projects for symbols.",
"typescript.workspaceSymbols.scope.currentProject": "Only search for symbols in the current JavaScript or TypeScript project.",
Expand Down
120 changes: 91 additions & 29 deletions extensions/typescript-language-features/src/languageFeatures/rename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,27 @@
import * as path from 'path';
import * as vscode from 'vscode';
import { DocumentSelector } from '../configuration/documentSelector';
import * as languageIds from '../configuration/languageIds';
import { API } from '../tsServer/api';
import type * as Proto from '../tsServer/protocol/protocol';
import * as typeConverters from '../typeConverters';
import { ClientCapability, ITypeScriptServiceClient, ServerResponse } from '../typescriptService';
import { ClientCapability, ITypeScriptServiceClient } from '../typescriptService';
import FileConfigurationManager from './fileConfigurationManager';
import { conditionalRegistration, requireSomeCapability } from './util/dependentRegistration';
import { LanguageDescription } from '../configuration/languageDescription';

type RenameResponse = {
readonly type: 'rename';
readonly body: Proto.RenameResponseBody;
} | {
readonly type: 'jsxLinkedEditing';
readonly spans: readonly Proto.TextSpan[];
};

class TypeScriptRenameProvider implements vscode.RenameProvider {

public constructor(
private readonly language: LanguageDescription,
private readonly client: ITypeScriptServiceClient,
private readonly fileConfigurationManager: FileConfigurationManager
) { }
Expand All @@ -24,74 +35,124 @@ class TypeScriptRenameProvider implements vscode.RenameProvider {
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken
): Promise<vscode.Range | null> {
): Promise<vscode.Range | undefined> {
if (this.client.apiVersion.lt(API.v310)) {
return null;
return undefined;
}

const response = await this.execRename(document, position, token);
if (response?.type !== 'response' || !response.body) {
return null;
if (!response) {
return undefined;
}

const renameInfo = response.body.info;
if (!renameInfo.canRename) {
return Promise.reject<vscode.Range>(renameInfo.localizedErrorMessage);
switch (response.type) {
case 'rename': {
const renameInfo = response.body.info;
if (!renameInfo.canRename) {
return Promise.reject<vscode.Range>(renameInfo.localizedErrorMessage);
}
return typeConverters.Range.fromTextSpan(renameInfo.triggerSpan);
}
case 'jsxLinkedEditing': {
return response.spans
.map(typeConverters.Range.fromTextSpan)
.find(range => range.contains(position));
}
}

return typeConverters.Range.fromTextSpan(renameInfo.triggerSpan);
}

public async provideRenameEdits(
document: vscode.TextDocument,
position: vscode.Position,
newName: string,
token: vscode.CancellationToken
): Promise<vscode.WorkspaceEdit | null> {
const response = await this.execRename(document, position, token);
if (!response || response.type !== 'response' || !response.body) {
return null;
): Promise<vscode.WorkspaceEdit | undefined> {
const file = this.client.toOpenTsFilePath(document);
if (!file) {
return undefined;
}

const renameInfo = response.body.info;
if (!renameInfo.canRename) {
return Promise.reject<vscode.WorkspaceEdit>(renameInfo.localizedErrorMessage);
const response = await this.execRename(document, position, token);
if (!response || token.isCancellationRequested) {
return undefined;
}

if (renameInfo.fileToRename) {
const edits = await this.renameFile(renameInfo.fileToRename, newName, token);
if (edits) {
return edits;
} else {
return Promise.reject<vscode.WorkspaceEdit>(vscode.l10n.t("An error occurred while renaming file"));
switch (response.type) {
case 'rename': {
const renameInfo = response.body.info;
if (!renameInfo.canRename) {
return Promise.reject<vscode.WorkspaceEdit>(renameInfo.localizedErrorMessage);
}

if (renameInfo.fileToRename) {
const edits = await this.renameFile(renameInfo.fileToRename, newName, token);
if (edits) {
return edits;
} else {
return Promise.reject<vscode.WorkspaceEdit>(vscode.l10n.t("An error occurred while renaming file"));
}
}

return this.updateLocs(response.body.locs, newName);
}
case 'jsxLinkedEditing': {
return this.updateLocs([{
file,
locs: response.spans.map((span): Proto.RenameTextSpan => ({ ...span })),
}], newName);
}
}

return this.updateLocs(response.body.locs, newName);
}

public async execRename(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken
): Promise<ServerResponse.Response<Proto.RenameResponse> | undefined> {
): Promise<RenameResponse | undefined> {
const file = this.client.toOpenTsFilePath(document);
if (!file) {
return undefined;
}

// Prefer renaming matching jsx tag when available
if (this.client.apiVersion.gte(API.v510) &&
vscode.workspace.getConfiguration(this.language.id).get('preferences.renameMatchingJsxTags', true) &&
this.looksLikePotentialJsxTagContext(document, position)
) {
const args = typeConverters.Position.toFileLocationRequestArgs(file, position);
const response = await this.client.execute('linkedEditingRange', args, token);
if (response.type !== 'response' || !response.body) {
return undefined;
}

return { type: 'jsxLinkedEditing', spans: response.body.ranges };
}

const args: Proto.RenameRequestArgs = {
...typeConverters.Position.toFileLocationRequestArgs(file, position),
findInStrings: false,
findInComments: false
};

return this.client.interruptGetErr(() => {
return this.client.interruptGetErr(async () => {
this.fileConfigurationManager.ensureConfigurationForDocument(document, token);
return this.client.execute('rename', args, token);
const response = await this.client.execute('rename', args, token);
if (response.type !== 'response' || !response.body) {
return undefined;
}
return { type: 'rename', body: response.body };
});
}

private looksLikePotentialJsxTagContext(document: vscode.TextDocument, position: vscode.Position): boolean {
if (![languageIds.typescriptreact, languageIds.javascript, languageIds.javascriptreact].includes(document.languageId)) {
return false;
}

const prefix = document.getText(new vscode.Range(position.line, 0, position.line, position.character));
return /\<\/?\s*[\w\d_$.]*$/.test(prefix);
}

private updateLocs(
locations: ReadonlyArray<Proto.SpanGroup>,
newName: string
Expand Down Expand Up @@ -138,13 +199,14 @@ class TypeScriptRenameProvider implements vscode.RenameProvider {

export function register(
selector: DocumentSelector,
language: LanguageDescription,
client: ITypeScriptServiceClient,
fileConfigurationManager: FileConfigurationManager,
) {
return conditionalRegistration([
requireSomeCapability(client, ClientCapability.Semantic),
], () => {
return vscode.languages.registerRenameProvider(selector.semantic,
new TypeScriptRenameProvider(client, fileConfigurationManager));
new TypeScriptRenameProvider(language, client, fileConfigurationManager));
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default class LanguageProvider extends Disposable {
import('./languageFeatures/quickFix').then(provider => this._register(provider.register(selector, this.client, this.fileConfigurationManager, this.commandManager, this.client.diagnosticsManager, this.telemetryReporter))),
import('./languageFeatures/refactor').then(provider => this._register(provider.register(selector, this.client, this.fileConfigurationManager, this.commandManager, this.telemetryReporter))),
import('./languageFeatures/references').then(provider => this._register(provider.register(selector, this.client))),
import('./languageFeatures/rename').then(provider => this._register(provider.register(selector, this.client, this.fileConfigurationManager))),
import('./languageFeatures/rename').then(provider => this._register(provider.register(selector, this.description, this.client, this.fileConfigurationManager))),
import('./languageFeatures/semanticTokens').then(provider => this._register(provider.register(selector, this.client))),
import('./languageFeatures/signatureHelp').then(provider => this._register(provider.register(selector, this.client))),
import('./languageFeatures/smartSelect').then(provider => this._register(provider.register(selector, this.client))),
Expand Down

0 comments on commit 330113c

Please sign in to comment.