Path: blob/main/src/vs/editor/contrib/rename/browser/rename.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { alert } from '../../../../base/browser/ui/aria/aria.js';6import { raceCancellation } from '../../../../base/common/async.js';7import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';8import { CancellationError, onUnexpectedError } from '../../../../base/common/errors.js';9import { isMarkdownString } from '../../../../base/common/htmlContent.js';10import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';11import { DisposableStore } from '../../../../base/common/lifecycle.js';12import { assertType } from '../../../../base/common/types.js';13import { URI } from '../../../../base/common/uri.js';14import * as nls from '../../../../nls.js';15import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';16import { ConfigurationScope, Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';17import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';18import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';19import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';20import { ILogService } from '../../../../platform/log/common/log.js';21import { INotificationService } from '../../../../platform/notification/common/notification.js';22import { IEditorProgressService } from '../../../../platform/progress/common/progress.js';23import { Registry } from '../../../../platform/registry/common/platform.js';24import { ICodeEditor } from '../../../browser/editorBrowser.js';25import { EditorAction, EditorCommand, EditorContributionInstantiation, ServicesAccessor, registerEditorAction, registerEditorCommand, registerEditorContribution, registerModelAndPositionCommand } from '../../../browser/editorExtensions.js';26import { IBulkEditService } from '../../../browser/services/bulkEditService.js';27import { ICodeEditorService } from '../../../browser/services/codeEditorService.js';28import { IPosition, Position } from '../../../common/core/position.js';29import { Range } from '../../../common/core/range.js';30import { IEditorContribution } from '../../../common/editorCommon.js';31import { EditorContextKeys } from '../../../common/editorContextKeys.js';32import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js';33import { NewSymbolNameTriggerKind, Rejection, RenameLocation, RenameProvider, WorkspaceEdit } from '../../../common/languages.js';34import { ITextModel } from '../../../common/model.js';35import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';36import { ITextResourceConfigurationService } from '../../../common/services/textResourceConfiguration.js';37import { EditSources } from '../../../common/textModelEditSource.js';38import { CodeEditorStateFlag, EditorStateCancellationTokenSource } from '../../editorState/browser/editorState.js';39import { MessageController } from '../../message/browser/messageController.js';40import { CONTEXT_RENAME_INPUT_VISIBLE, RenameWidget } from './renameWidget.js';4142class RenameSkeleton {4344private readonly _providers: RenameProvider[];45private _providerRenameIdx: number = 0;4647constructor(48private readonly model: ITextModel,49private readonly position: Position,50registry: LanguageFeatureRegistry<RenameProvider>51) {52this._providers = registry.ordered(model);53}5455hasProvider() {56return this._providers.length > 0;57}5859async resolveRenameLocation(token: CancellationToken): Promise<RenameLocation & Rejection | undefined> {6061const rejects: string[] = [];6263for (this._providerRenameIdx = 0; this._providerRenameIdx < this._providers.length; this._providerRenameIdx++) {64const provider = this._providers[this._providerRenameIdx];65if (!provider.resolveRenameLocation) {66break;67}68const res = await provider.resolveRenameLocation(this.model, this.position, token);69if (!res) {70continue;71}72if (res.rejectReason) {73rejects.push(res.rejectReason);74continue;75}76return res;77}7879// we are here when no provider prepared a location which means we can80// just rely on the word under cursor and start with the first provider81this._providerRenameIdx = 0;8283const word = this.model.getWordAtPosition(this.position);84if (!word) {85return {86range: Range.fromPositions(this.position),87text: '',88rejectReason: rejects.length > 0 ? rejects.join('\n') : undefined89};90}91return {92range: new Range(this.position.lineNumber, word.startColumn, this.position.lineNumber, word.endColumn),93text: word.word,94rejectReason: rejects.length > 0 ? rejects.join('\n') : undefined95};96}9798async provideRenameEdits(newName: string, token: CancellationToken): Promise<WorkspaceEdit & Rejection> {99return this._provideRenameEdits(newName, this._providerRenameIdx, [], token);100}101102private async _provideRenameEdits(newName: string, i: number, rejects: string[], token: CancellationToken): Promise<WorkspaceEdit & Rejection> {103const provider = this._providers[i];104if (!provider) {105return {106edits: [],107rejectReason: rejects.join('\n')108};109}110111const result = await provider.provideRenameEdits(this.model, this.position, newName, token);112if (!result) {113return this._provideRenameEdits(newName, i + 1, rejects.concat(nls.localize('no result', "No result.")), token);114} else if (result.rejectReason) {115return this._provideRenameEdits(newName, i + 1, rejects.concat(result.rejectReason), token);116}117return result;118}119}120121export async function rename(registry: LanguageFeatureRegistry<RenameProvider>, model: ITextModel, position: Position, newName: string): Promise<WorkspaceEdit & Rejection> {122const skeleton = new RenameSkeleton(model, position, registry);123const loc = await skeleton.resolveRenameLocation(CancellationToken.None);124if (loc?.rejectReason) {125return { edits: [], rejectReason: loc.rejectReason };126}127return skeleton.provideRenameEdits(newName, CancellationToken.None);128}129130// --- register actions and commands131132class RenameController implements IEditorContribution {133134public static readonly ID = 'editor.contrib.renameController';135136static get(editor: ICodeEditor): RenameController | null {137return editor.getContribution<RenameController>(RenameController.ID);138}139140private readonly _renameWidget: RenameWidget;141private readonly _disposableStore = new DisposableStore();142private _cts: CancellationTokenSource = new CancellationTokenSource();143144constructor(145private readonly editor: ICodeEditor,146@IInstantiationService private readonly _instaService: IInstantiationService,147@INotificationService private readonly _notificationService: INotificationService,148@IBulkEditService private readonly _bulkEditService: IBulkEditService,149@IEditorProgressService private readonly _progressService: IEditorProgressService,150@ILogService private readonly _logService: ILogService,151@ITextResourceConfigurationService private readonly _configService: ITextResourceConfigurationService,152@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,153) {154this._renameWidget = this._disposableStore.add(this._instaService.createInstance(RenameWidget, this.editor, ['acceptRenameInput', 'acceptRenameInputWithPreview']));155}156157dispose(): void {158this._disposableStore.dispose();159this._cts.dispose(true);160}161162async run(): Promise<void> {163164const trace = this._logService.trace.bind(this._logService, '[rename]');165166// set up cancellation token to prevent reentrant rename, this167// is the parent to the resolve- and rename-tokens168this._cts.dispose(true);169this._cts = new CancellationTokenSource();170171if (!this.editor.hasModel()) {172trace('editor has no model');173return undefined;174}175176const position = this.editor.getPosition();177const skeleton = new RenameSkeleton(this.editor.getModel(), position, this._languageFeaturesService.renameProvider);178179if (!skeleton.hasProvider()) {180trace('skeleton has no provider');181return undefined;182}183184// part 1 - resolve rename location185const cts1 = new EditorStateCancellationTokenSource(this.editor, CodeEditorStateFlag.Position | CodeEditorStateFlag.Value, undefined, this._cts.token);186187let loc: RenameLocation & Rejection | undefined;188try {189trace('resolving rename location');190const resolveLocationOperation = skeleton.resolveRenameLocation(cts1.token);191this._progressService.showWhile(resolveLocationOperation, 250);192loc = await resolveLocationOperation;193trace('resolved rename location');194} catch (e: unknown) {195if (e instanceof CancellationError) {196trace('resolve rename location cancelled', JSON.stringify(e, null, '\t'));197} else {198trace('resolve rename location failed', e instanceof Error ? e : JSON.stringify(e, null, '\t'));199if (typeof e === 'string' || isMarkdownString(e)) {200MessageController.get(this.editor)?.showMessage(e || nls.localize('resolveRenameLocationFailed', "An unknown error occurred while resolving rename location"), position);201}202}203return undefined;204205} finally {206cts1.dispose();207}208209if (!loc) {210trace('returning early - no loc');211return undefined;212}213214if (loc.rejectReason) {215trace(`returning early - rejected with reason: ${loc.rejectReason}`, loc.rejectReason);216MessageController.get(this.editor)?.showMessage(loc.rejectReason, position);217return undefined;218}219220if (cts1.token.isCancellationRequested) {221trace('returning early - cts1 cancelled');222return undefined;223}224225// part 2 - do rename at location226const cts2 = new EditorStateCancellationTokenSource(this.editor, CodeEditorStateFlag.Position | CodeEditorStateFlag.Value, loc.range, this._cts.token);227228const model = this.editor.getModel(); // @ulugbekna: assumes editor still has a model, otherwise, cts1 should've been cancelled229230const newSymbolNamesProviders = this._languageFeaturesService.newSymbolNamesProvider.all(model);231232const resolvedNewSymbolnamesProviders = await Promise.all(newSymbolNamesProviders.map(async p => [p, await p.supportsAutomaticNewSymbolNamesTriggerKind ?? false] as const));233234const requestRenameSuggestions = (triggerKind: NewSymbolNameTriggerKind, cts: CancellationToken) => {235let providers = resolvedNewSymbolnamesProviders.slice();236237if (triggerKind === NewSymbolNameTriggerKind.Automatic) {238providers = providers.filter(([_, supportsAutomatic]) => supportsAutomatic);239}240241return providers.map(([p,]) => p.provideNewSymbolNames(model, loc.range, triggerKind, cts));242};243244trace('creating rename input field and awaiting its result');245const supportPreview = this._bulkEditService.hasPreviewHandler() && this._configService.getValue<boolean>(this.editor.getModel().uri, 'editor.rename.enablePreview');246const inputFieldResult = await this._renameWidget.getInput(247loc.range,248loc.text,249supportPreview,250newSymbolNamesProviders.length > 0 ? requestRenameSuggestions : undefined,251cts2252);253trace('received response from rename input field');254255// no result, only hint to focus the editor or not256if (typeof inputFieldResult === 'boolean') {257trace(`returning early - rename input field response - ${inputFieldResult}`);258if (inputFieldResult) {259this.editor.focus();260}261cts2.dispose();262return undefined;263}264265this.editor.focus();266267trace('requesting rename edits');268const renameOperation = raceCancellation(skeleton.provideRenameEdits(inputFieldResult.newName, cts2.token), cts2.token).then(async renameResult => {269270if (!renameResult) {271trace('returning early - no rename edits result');272return;273}274if (!this.editor.hasModel()) {275trace('returning early - no model after rename edits are provided');276return;277}278279if (renameResult.rejectReason) {280trace(`returning early - rejected with reason: ${renameResult.rejectReason}`);281this._notificationService.info(renameResult.rejectReason);282return;283}284285// collapse selection to active end286this.editor.setSelection(Range.fromPositions(this.editor.getSelection().getPosition()));287288trace('applying edits');289290this._bulkEditService.apply(renameResult, {291editor: this.editor,292showPreview: inputFieldResult.wantsPreview,293label: nls.localize('label', "Renaming '{0}' to '{1}'", loc?.text, inputFieldResult.newName),294code: 'undoredo.rename',295quotableLabel: nls.localize('quotableLabel', "Renaming {0} to {1}", loc?.text, inputFieldResult.newName),296respectAutoSaveConfig: true,297reason: EditSources.rename(),298}).then(result => {299trace('edits applied');300if (result.ariaSummary) {301alert(nls.localize('aria', "Successfully renamed '{0}' to '{1}'. Summary: {2}", loc.text, inputFieldResult.newName, result.ariaSummary));302}303}).catch(err => {304trace(`error when applying edits ${JSON.stringify(err, null, '\t')}`);305this._notificationService.error(nls.localize('rename.failedApply', "Rename failed to apply edits"));306this._logService.error(err);307});308309}, err => {310trace('error when providing rename edits', JSON.stringify(err, null, '\t'));311312this._notificationService.error(nls.localize('rename.failed', "Rename failed to compute edits"));313this._logService.error(err);314315}).finally(() => {316cts2.dispose();317});318319trace('returning rename operation');320321this._progressService.showWhile(renameOperation, 250);322return renameOperation;323324}325326acceptRenameInput(wantsPreview: boolean): void {327this._renameWidget.acceptInput(wantsPreview);328}329330cancelRenameInput(): void {331this._renameWidget.cancelInput(true, 'cancelRenameInput command');332}333334focusNextRenameSuggestion(): void {335this._renameWidget.focusNextRenameSuggestion();336}337338focusPreviousRenameSuggestion(): void {339this._renameWidget.focusPreviousRenameSuggestion();340}341}342343// ---- action implementation344345export class RenameAction extends EditorAction {346347constructor() {348super({349id: 'editor.action.rename',350label: nls.localize2('rename.label', "Rename Symbol"),351precondition: ContextKeyExpr.and(EditorContextKeys.writable, EditorContextKeys.hasRenameProvider),352kbOpts: {353kbExpr: EditorContextKeys.editorTextFocus,354primary: KeyCode.F2,355weight: KeybindingWeight.EditorContrib356},357contextMenuOpts: {358group: '1_modification',359order: 1.1360}361});362}363364override runCommand(accessor: ServicesAccessor, args: [URI, IPosition]): void | Promise<void> {365const editorService = accessor.get(ICodeEditorService);366const [uri, pos] = Array.isArray(args) && args || [undefined, undefined];367368if (URI.isUri(uri) && Position.isIPosition(pos)) {369return editorService.openCodeEditor({ resource: uri }, editorService.getActiveCodeEditor()).then(editor => {370if (!editor) {371return;372}373editor.setPosition(pos);374editor.invokeWithinContext(accessor => {375this.reportTelemetry(accessor, editor);376return this.run(accessor, editor);377});378}, onUnexpectedError);379}380381return super.runCommand(accessor, args);382}383384run(accessor: ServicesAccessor, editor: ICodeEditor): Promise<void> {385const logService = accessor.get(ILogService);386387const controller = RenameController.get(editor);388389if (controller) {390logService.trace('[RenameAction] got controller, running...');391return controller.run();392}393logService.trace('[RenameAction] returning early - controller missing');394return Promise.resolve();395}396}397398registerEditorContribution(RenameController.ID, RenameController, EditorContributionInstantiation.Lazy);399registerEditorAction(RenameAction);400401const RenameCommand = EditorCommand.bindToContribution<RenameController>(RenameController.get);402403registerEditorCommand(new RenameCommand({404id: 'acceptRenameInput',405precondition: CONTEXT_RENAME_INPUT_VISIBLE,406handler: x => x.acceptRenameInput(false),407kbOpts: {408weight: KeybindingWeight.EditorContrib + 99,409kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, ContextKeyExpr.not('isComposing')),410primary: KeyCode.Enter411}412}));413414registerEditorCommand(new RenameCommand({415id: 'acceptRenameInputWithPreview',416precondition: ContextKeyExpr.and(CONTEXT_RENAME_INPUT_VISIBLE, ContextKeyExpr.has('config.editor.rename.enablePreview')),417handler: x => x.acceptRenameInput(true),418kbOpts: {419weight: KeybindingWeight.EditorContrib + 99,420kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, ContextKeyExpr.not('isComposing')),421primary: KeyMod.CtrlCmd + KeyCode.Enter422}423}));424425registerEditorCommand(new RenameCommand({426id: 'cancelRenameInput',427precondition: CONTEXT_RENAME_INPUT_VISIBLE,428handler: x => x.cancelRenameInput(),429kbOpts: {430weight: KeybindingWeight.EditorContrib + 99,431kbExpr: EditorContextKeys.focus,432primary: KeyCode.Escape,433secondary: [KeyMod.Shift | KeyCode.Escape]434}435}));436437registerAction2(class FocusNextRenameSuggestion extends Action2 {438constructor() {439super({440id: 'focusNextRenameSuggestion',441title: {442...nls.localize2('focusNextRenameSuggestion', "Focus Next Rename Suggestion"),443},444precondition: CONTEXT_RENAME_INPUT_VISIBLE,445keybinding: [446{447primary: KeyCode.DownArrow,448weight: KeybindingWeight.EditorContrib + 99,449}450]451});452}453454override run(accessor: ServicesAccessor): void {455const currentEditor = accessor.get(ICodeEditorService).getFocusedCodeEditor();456if (!currentEditor) { return; }457458const controller = RenameController.get(currentEditor);459if (!controller) { return; }460461controller.focusNextRenameSuggestion();462}463});464465registerAction2(class FocusPreviousRenameSuggestion extends Action2 {466constructor() {467super({468id: 'focusPreviousRenameSuggestion',469title: {470...nls.localize2('focusPreviousRenameSuggestion', "Focus Previous Rename Suggestion"),471},472precondition: CONTEXT_RENAME_INPUT_VISIBLE,473keybinding: [474{475primary: KeyCode.UpArrow,476weight: KeybindingWeight.EditorContrib + 99,477}478]479});480}481482override run(accessor: ServicesAccessor): void {483const currentEditor = accessor.get(ICodeEditorService).getFocusedCodeEditor();484if (!currentEditor) { return; }485486const controller = RenameController.get(currentEditor);487if (!controller) { return; }488489controller.focusPreviousRenameSuggestion();490}491});492493// ---- api bridge command494495registerModelAndPositionCommand('_executeDocumentRenameProvider', function (accessor, model, position, ...args) {496const [newName] = args;497assertType(typeof newName === 'string');498const { renameProvider } = accessor.get(ILanguageFeaturesService);499return rename(renameProvider, model, position, newName);500});501502registerModelAndPositionCommand('_executePrepareRename', async function (accessor, model, position) {503const { renameProvider } = accessor.get(ILanguageFeaturesService);504const skeleton = new RenameSkeleton(model, position, renameProvider);505const loc = await skeleton.resolveRenameLocation(CancellationToken.None);506if (loc?.rejectReason) {507throw new Error(loc.rejectReason);508}509return loc;510});511512513//todo@jrieken use editor options world514Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfiguration({515id: 'editor',516properties: {517'editor.rename.enablePreview': {518scope: ConfigurationScope.LANGUAGE_OVERRIDABLE,519description: nls.localize('enablePreview', "Enable/disable the ability to preview changes before renaming"),520default: true,521type: 'boolean'522}523}524});525526527