Path: blob/main/src/vs/editor/contrib/linkedEditing/browser/linkedEditing.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 * as arrays from '../../../../base/common/arrays.js';6import { Delayer, first } from '../../../../base/common/async.js';7import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';8import { Color } from '../../../../base/common/color.js';9import { isCancellationError, onUnexpectedError, onUnexpectedExternalError } from '../../../../base/common/errors.js';10import { Event } from '../../../../base/common/event.js';11import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';12import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';13import * as strings from '../../../../base/common/strings.js';14import { URI } from '../../../../base/common/uri.js';15import { ICodeEditor } from '../../../browser/editorBrowser.js';16import { EditorAction, EditorCommand, EditorContributionInstantiation, registerEditorAction, registerEditorCommand, registerEditorContribution, registerModelAndPositionCommand, ServicesAccessor } from '../../../browser/editorExtensions.js';17import { ICodeEditorService } from '../../../browser/services/codeEditorService.js';18import { EditorOption } from '../../../common/config/editorOptions.js';19import { IPosition, Position } from '../../../common/core/position.js';20import { IRange, Range } from '../../../common/core/range.js';21import { IEditorContribution, IEditorDecorationsCollection } from '../../../common/editorCommon.js';22import { EditorContextKeys } from '../../../common/editorContextKeys.js';23import { IModelDeltaDecoration, ITextModel, TrackedRangeStickiness } from '../../../common/model.js';24import { ModelDecorationOptions } from '../../../common/model/textModel.js';25import { LinkedEditingRangeProvider, LinkedEditingRanges } from '../../../common/languages.js';26import { ILanguageConfigurationService } from '../../../common/languages/languageConfigurationRegistry.js';27import * as nls from '../../../../nls.js';28import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';29import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';30import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';31import { registerColor } from '../../../../platform/theme/common/colorRegistry.js';32import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js';33import { ISingleEditOperation } from '../../../common/core/editOperation.js';34import { IFeatureDebounceInformation, ILanguageFeatureDebounceService } from '../../../common/services/languageFeatureDebounce.js';35import { StopWatch } from '../../../../base/common/stopwatch.js';36import './linkedEditing.css';3738export const CONTEXT_ONTYPE_RENAME_INPUT_VISIBLE = new RawContextKey<boolean>('LinkedEditingInputVisible', false);3940const DECORATION_CLASS_NAME = 'linked-editing-decoration';4142export class LinkedEditingContribution extends Disposable implements IEditorContribution {4344public static readonly ID = 'editor.contrib.linkedEditing';4546private static readonly DECORATION = ModelDecorationOptions.register({47description: 'linked-editing',48stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,49className: DECORATION_CLASS_NAME50});5152static get(editor: ICodeEditor): LinkedEditingContribution | null {53return editor.getContribution<LinkedEditingContribution>(LinkedEditingContribution.ID);54}5556private _debounceDuration: number | undefined;5758private readonly _editor: ICodeEditor;59private readonly _providers: LanguageFeatureRegistry<LinkedEditingRangeProvider>;60private _enabled: boolean;6162private readonly _visibleContextKey: IContextKey<boolean>;63private readonly _debounceInformation: IFeatureDebounceInformation;6465private _rangeUpdateTriggerPromise: Promise<any> | null;66private _rangeSyncTriggerPromise: Promise<any> | null;6768private _currentRequestCts: CancellationTokenSource | null;69private _currentRequestPosition: Position | null;70private _currentRequestModelVersion: number | null;7172private _currentDecorations: IEditorDecorationsCollection; // The one at index 0 is the reference one73private _syncRangesToken: number = 0;7475private _languageWordPattern: RegExp | null;76private _currentWordPattern: RegExp | null;77private _ignoreChangeEvent: boolean;7879private readonly _localToDispose = this._register(new DisposableStore());8081constructor(82editor: ICodeEditor,83@IContextKeyService contextKeyService: IContextKeyService,84@ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService,85@ILanguageConfigurationService private readonly languageConfigurationService: ILanguageConfigurationService,86@ILanguageFeatureDebounceService languageFeatureDebounceService: ILanguageFeatureDebounceService87) {88super();89this._editor = editor;90this._providers = languageFeaturesService.linkedEditingRangeProvider;91this._enabled = false;92this._visibleContextKey = CONTEXT_ONTYPE_RENAME_INPUT_VISIBLE.bindTo(contextKeyService);93this._debounceInformation = languageFeatureDebounceService.for(this._providers, 'Linked Editing', { max: 200 });9495this._currentDecorations = this._editor.createDecorationsCollection();96this._languageWordPattern = null;97this._currentWordPattern = null;98this._ignoreChangeEvent = false;99this._localToDispose = this._register(new DisposableStore());100101this._rangeUpdateTriggerPromise = null;102this._rangeSyncTriggerPromise = null;103104this._currentRequestCts = null;105this._currentRequestPosition = null;106this._currentRequestModelVersion = null;107108this._register(this._editor.onDidChangeModel(() => this.reinitialize(true)));109110this._register(this._editor.onDidChangeConfiguration(e => {111if (e.hasChanged(EditorOption.linkedEditing) || e.hasChanged(EditorOption.renameOnType)) {112this.reinitialize(false);113}114}));115this._register(this._providers.onDidChange(() => this.reinitialize(false)));116this._register(this._editor.onDidChangeModelLanguage(() => this.reinitialize(true)));117118this.reinitialize(true);119}120121private reinitialize(forceRefresh: boolean) {122const model = this._editor.getModel();123const isEnabled = model !== null && (this._editor.getOption(EditorOption.linkedEditing) || this._editor.getOption(EditorOption.renameOnType)) && this._providers.has(model);124if (isEnabled === this._enabled && !forceRefresh) {125return;126}127128this._enabled = isEnabled;129130this.clearRanges();131this._localToDispose.clear();132133if (!isEnabled || model === null) {134return;135}136137this._localToDispose.add(138Event.runAndSubscribe(139model.onDidChangeLanguageConfiguration,140() => {141this._languageWordPattern = this.languageConfigurationService.getLanguageConfiguration(model.getLanguageId()).getWordDefinition();142}143)144);145146const rangeUpdateScheduler = new Delayer(this._debounceInformation.get(model));147const triggerRangeUpdate = () => {148this._rangeUpdateTriggerPromise = rangeUpdateScheduler.trigger(() => this.updateRanges(), this._debounceDuration ?? this._debounceInformation.get(model));149};150const rangeSyncScheduler = new Delayer(0);151const triggerRangeSync = (token: number) => {152this._rangeSyncTriggerPromise = rangeSyncScheduler.trigger(() => this._syncRanges(token));153};154this._localToDispose.add(this._editor.onDidChangeCursorPosition(() => {155triggerRangeUpdate();156}));157this._localToDispose.add(this._editor.onDidChangeModelContent((e) => {158if (!this._ignoreChangeEvent) {159if (this._currentDecorations.length > 0) {160const referenceRange = this._currentDecorations.getRange(0);161if (referenceRange && e.changes.every(c => referenceRange.intersectRanges(c.range))) {162triggerRangeSync(this._syncRangesToken);163return;164}165}166}167triggerRangeUpdate();168}));169this._localToDispose.add({170dispose: () => {171rangeUpdateScheduler.dispose();172rangeSyncScheduler.dispose();173}174});175this.updateRanges();176}177178private _syncRanges(token: number): void {179// delayed invocation, make sure we're still on180if (!this._editor.hasModel() || token !== this._syncRangesToken || this._currentDecorations.length === 0) {181// nothing to do182return;183}184185const model = this._editor.getModel();186const referenceRange = this._currentDecorations.getRange(0);187188if (!referenceRange || referenceRange.startLineNumber !== referenceRange.endLineNumber) {189return this.clearRanges();190}191192const referenceValue = model.getValueInRange(referenceRange);193if (this._currentWordPattern) {194const match = referenceValue.match(this._currentWordPattern);195const matchLength = match ? match[0].length : 0;196if (matchLength !== referenceValue.length) {197return this.clearRanges();198}199}200201const edits: ISingleEditOperation[] = [];202for (let i = 1, len = this._currentDecorations.length; i < len; i++) {203const mirrorRange = this._currentDecorations.getRange(i);204if (!mirrorRange) {205continue;206}207if (mirrorRange.startLineNumber !== mirrorRange.endLineNumber) {208edits.push({209range: mirrorRange,210text: referenceValue211});212} else {213let oldValue = model.getValueInRange(mirrorRange);214let newValue = referenceValue;215let rangeStartColumn = mirrorRange.startColumn;216let rangeEndColumn = mirrorRange.endColumn;217218const commonPrefixLength = strings.commonPrefixLength(oldValue, newValue);219rangeStartColumn += commonPrefixLength;220oldValue = oldValue.substr(commonPrefixLength);221newValue = newValue.substr(commonPrefixLength);222223const commonSuffixLength = strings.commonSuffixLength(oldValue, newValue);224rangeEndColumn -= commonSuffixLength;225oldValue = oldValue.substr(0, oldValue.length - commonSuffixLength);226newValue = newValue.substr(0, newValue.length - commonSuffixLength);227228if (rangeStartColumn !== rangeEndColumn || newValue.length !== 0) {229edits.push({230range: new Range(mirrorRange.startLineNumber, rangeStartColumn, mirrorRange.endLineNumber, rangeEndColumn),231text: newValue232});233}234}235}236237if (edits.length === 0) {238return;239}240241try {242this._editor.popUndoStop();243this._ignoreChangeEvent = true;244const prevEditOperationType = this._editor._getViewModel().getPrevEditOperationType();245this._editor.executeEdits('linkedEditing', edits);246this._editor._getViewModel().setPrevEditOperationType(prevEditOperationType);247} finally {248this._ignoreChangeEvent = false;249}250}251252public override dispose(): void {253this.clearRanges();254super.dispose();255}256257public clearRanges(): void {258this._visibleContextKey.set(false);259this._currentDecorations.clear();260if (this._currentRequestCts) {261this._currentRequestCts.cancel();262this._currentRequestCts = null;263this._currentRequestPosition = null;264}265}266267public get currentUpdateTriggerPromise(): Promise<any> {268return this._rangeUpdateTriggerPromise || Promise.resolve();269}270271public get currentSyncTriggerPromise(): Promise<any> {272return this._rangeSyncTriggerPromise || Promise.resolve();273}274275public async updateRanges(force = false): Promise<void> {276if (!this._editor.hasModel()) {277this.clearRanges();278return;279}280281const position = this._editor.getPosition();282if (!this._enabled && !force || this._editor.getSelections().length > 1) {283// disabled or multicursor284this.clearRanges();285return;286}287288const model = this._editor.getModel();289const modelVersionId = model.getVersionId();290if (this._currentRequestPosition && this._currentRequestModelVersion === modelVersionId) {291if (position.equals(this._currentRequestPosition)) {292return; // same position293}294if (this._currentDecorations.length > 0) {295const range = this._currentDecorations.getRange(0);296if (range && range.containsPosition(position)) {297return; // just moving inside the existing primary range298}299}300}301302if (!this._currentRequestPosition?.equals(position)) {303// Get the current range of the first decoration (reference range)304const currentRange = this._currentDecorations.getRange(0);305// If there is no current range or the current range does not contain the new position, clear the ranges306if (!currentRange?.containsPosition(position)) {307// Clear existing decorations while we compute new ones308this.clearRanges();309}310}311312this._currentRequestPosition = position;313this._currentRequestModelVersion = modelVersionId;314315const currentRequestCts = this._currentRequestCts = new CancellationTokenSource();316try {317const sw = new StopWatch(false);318const response = await getLinkedEditingRanges(this._providers, model, position, currentRequestCts.token);319this._debounceInformation.update(model, sw.elapsed());320if (currentRequestCts !== this._currentRequestCts) {321return;322}323this._currentRequestCts = null;324if (modelVersionId !== model.getVersionId()) {325return;326}327328let ranges: IRange[] = [];329if (response?.ranges) {330ranges = response.ranges;331}332333this._currentWordPattern = response?.wordPattern || this._languageWordPattern;334335let foundReferenceRange = false;336for (let i = 0, len = ranges.length; i < len; i++) {337if (Range.containsPosition(ranges[i], position)) {338foundReferenceRange = true;339if (i !== 0) {340const referenceRange = ranges[i];341ranges.splice(i, 1);342ranges.unshift(referenceRange);343}344break;345}346}347348if (!foundReferenceRange) {349// Cannot do linked editing if the ranges are not where the cursor is...350this.clearRanges();351return;352}353354const decorations: IModelDeltaDecoration[] = ranges.map(range => ({ range: range, options: LinkedEditingContribution.DECORATION }));355this._visibleContextKey.set(true);356this._currentDecorations.set(decorations);357this._syncRangesToken++; // cancel any pending syncRanges call358} catch (err) {359if (!isCancellationError(err)) {360onUnexpectedError(err);361}362if (this._currentRequestCts === currentRequestCts || !this._currentRequestCts) {363// stop if we are still the latest request364this.clearRanges();365}366}367368}369370// for testing371public setDebounceDuration(timeInMS: number) {372this._debounceDuration = timeInMS;373}374375// private printDecorators(model: ITextModel) {376// return this._currentDecorations.map(d => {377// const range = model.getDecorationRange(d);378// if (range) {379// return this.printRange(range);380// }381// return 'invalid';382// }).join(',');383// }384385// private printChanges(changes: IModelContentChange[]) {386// return changes.map(c => {387// return `${this.printRange(c.range)} - ${c.text}`;388// }389// ).join(',');390// }391392// private printRange(range: IRange) {393// return `${range.startLineNumber},${range.startColumn}/${range.endLineNumber},${range.endColumn}`;394// }395}396397export class LinkedEditingAction extends EditorAction {398constructor() {399super({400id: 'editor.action.linkedEditing',401label: nls.localize2('linkedEditing.label', "Start Linked Editing"),402precondition: ContextKeyExpr.and(EditorContextKeys.writable, EditorContextKeys.hasRenameProvider),403kbOpts: {404kbExpr: EditorContextKeys.editorTextFocus,405primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.F2,406weight: KeybindingWeight.EditorContrib407}408});409}410411override runCommand(accessor: ServicesAccessor, args: [URI, IPosition]): void | Promise<void> {412const editorService = accessor.get(ICodeEditorService);413const [uri, pos] = Array.isArray(args) && args || [undefined, undefined];414415if (URI.isUri(uri) && Position.isIPosition(pos)) {416return editorService.openCodeEditor({ resource: uri }, editorService.getActiveCodeEditor()).then(editor => {417if (!editor) {418return;419}420editor.setPosition(pos);421editor.invokeWithinContext(accessor => {422this.reportTelemetry(accessor, editor);423return this.run(accessor, editor);424});425}, onUnexpectedError);426}427428return super.runCommand(accessor, args);429}430431run(_accessor: ServicesAccessor, editor: ICodeEditor): Promise<void> {432const controller = LinkedEditingContribution.get(editor);433if (controller) {434return Promise.resolve(controller.updateRanges(true));435}436return Promise.resolve();437}438}439440const LinkedEditingCommand = EditorCommand.bindToContribution<LinkedEditingContribution>(LinkedEditingContribution.get);441registerEditorCommand(new LinkedEditingCommand({442id: 'cancelLinkedEditingInput',443precondition: CONTEXT_ONTYPE_RENAME_INPUT_VISIBLE,444handler: x => x.clearRanges(),445kbOpts: {446kbExpr: EditorContextKeys.editorTextFocus,447weight: KeybindingWeight.EditorContrib + 99,448primary: KeyCode.Escape,449secondary: [KeyMod.Shift | KeyCode.Escape]450}451}));452453454function getLinkedEditingRanges(providers: LanguageFeatureRegistry<LinkedEditingRangeProvider>, model: ITextModel, position: Position, token: CancellationToken): Promise<LinkedEditingRanges | undefined | null> {455const orderedByScore = providers.ordered(model);456457// in order of score ask the linked editing range provider458// until someone response with a good result459// (good = not null)460return first<LinkedEditingRanges | undefined | null>(orderedByScore.map(provider => async () => {461try {462return await provider.provideLinkedEditingRanges(model, position, token);463} catch (e) {464onUnexpectedExternalError(e);465return undefined;466}467}), result => !!result && arrays.isNonEmptyArray(result?.ranges));468}469470export const editorLinkedEditingBackground = registerColor('editor.linkedEditingBackground', { dark: Color.fromHex('#f00').transparent(0.3), light: Color.fromHex('#f00').transparent(0.3), hcDark: Color.fromHex('#f00').transparent(0.3), hcLight: Color.white }, nls.localize('editorLinkedEditingBackground', 'Background color when the editor auto renames on type.'));471472registerModelAndPositionCommand('_executeLinkedEditingProvider', (_accessor, model, position) => {473const { linkedEditingRangeProvider } = _accessor.get(ILanguageFeaturesService);474return getLinkedEditingRanges(linkedEditingRangeProvider, model, position, CancellationToken.None);475});476477registerEditorContribution(LinkedEditingContribution.ID, LinkedEditingContribution, EditorContributionInstantiation.AfterFirstRender);478registerEditorAction(LinkedEditingAction);479480481