Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/contrib/linkedEditing/browser/linkedEditing.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as arrays from '../../../../base/common/arrays.js';
7
import { Delayer, first } from '../../../../base/common/async.js';
8
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
9
import { Color } from '../../../../base/common/color.js';
10
import { isCancellationError, onUnexpectedError, onUnexpectedExternalError } from '../../../../base/common/errors.js';
11
import { Event } from '../../../../base/common/event.js';
12
import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';
13
import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
14
import * as strings from '../../../../base/common/strings.js';
15
import { URI } from '../../../../base/common/uri.js';
16
import { ICodeEditor } from '../../../browser/editorBrowser.js';
17
import { EditorAction, EditorCommand, EditorContributionInstantiation, registerEditorAction, registerEditorCommand, registerEditorContribution, registerModelAndPositionCommand, ServicesAccessor } from '../../../browser/editorExtensions.js';
18
import { ICodeEditorService } from '../../../browser/services/codeEditorService.js';
19
import { EditorOption } from '../../../common/config/editorOptions.js';
20
import { IPosition, Position } from '../../../common/core/position.js';
21
import { IRange, Range } from '../../../common/core/range.js';
22
import { IEditorContribution, IEditorDecorationsCollection } from '../../../common/editorCommon.js';
23
import { EditorContextKeys } from '../../../common/editorContextKeys.js';
24
import { IModelDeltaDecoration, ITextModel, TrackedRangeStickiness } from '../../../common/model.js';
25
import { ModelDecorationOptions } from '../../../common/model/textModel.js';
26
import { LinkedEditingRangeProvider, LinkedEditingRanges } from '../../../common/languages.js';
27
import { ILanguageConfigurationService } from '../../../common/languages/languageConfigurationRegistry.js';
28
import * as nls from '../../../../nls.js';
29
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
30
import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
31
import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';
32
import { registerColor } from '../../../../platform/theme/common/colorRegistry.js';
33
import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js';
34
import { ISingleEditOperation } from '../../../common/core/editOperation.js';
35
import { IFeatureDebounceInformation, ILanguageFeatureDebounceService } from '../../../common/services/languageFeatureDebounce.js';
36
import { StopWatch } from '../../../../base/common/stopwatch.js';
37
import './linkedEditing.css';
38
39
export const CONTEXT_ONTYPE_RENAME_INPUT_VISIBLE = new RawContextKey<boolean>('LinkedEditingInputVisible', false);
40
41
const DECORATION_CLASS_NAME = 'linked-editing-decoration';
42
43
export class LinkedEditingContribution extends Disposable implements IEditorContribution {
44
45
public static readonly ID = 'editor.contrib.linkedEditing';
46
47
private static readonly DECORATION = ModelDecorationOptions.register({
48
description: 'linked-editing',
49
stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges,
50
className: DECORATION_CLASS_NAME
51
});
52
53
static get(editor: ICodeEditor): LinkedEditingContribution | null {
54
return editor.getContribution<LinkedEditingContribution>(LinkedEditingContribution.ID);
55
}
56
57
private _debounceDuration: number | undefined;
58
59
private readonly _editor: ICodeEditor;
60
private readonly _providers: LanguageFeatureRegistry<LinkedEditingRangeProvider>;
61
private _enabled: boolean;
62
63
private readonly _visibleContextKey: IContextKey<boolean>;
64
private readonly _debounceInformation: IFeatureDebounceInformation;
65
66
private _rangeUpdateTriggerPromise: Promise<any> | null;
67
private _rangeSyncTriggerPromise: Promise<any> | null;
68
69
private _currentRequestCts: CancellationTokenSource | null;
70
private _currentRequestPosition: Position | null;
71
private _currentRequestModelVersion: number | null;
72
73
private _currentDecorations: IEditorDecorationsCollection; // The one at index 0 is the reference one
74
private _syncRangesToken: number = 0;
75
76
private _languageWordPattern: RegExp | null;
77
private _currentWordPattern: RegExp | null;
78
private _ignoreChangeEvent: boolean;
79
80
private readonly _localToDispose = this._register(new DisposableStore());
81
82
constructor(
83
editor: ICodeEditor,
84
@IContextKeyService contextKeyService: IContextKeyService,
85
@ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService,
86
@ILanguageConfigurationService private readonly languageConfigurationService: ILanguageConfigurationService,
87
@ILanguageFeatureDebounceService languageFeatureDebounceService: ILanguageFeatureDebounceService
88
) {
89
super();
90
this._editor = editor;
91
this._providers = languageFeaturesService.linkedEditingRangeProvider;
92
this._enabled = false;
93
this._visibleContextKey = CONTEXT_ONTYPE_RENAME_INPUT_VISIBLE.bindTo(contextKeyService);
94
this._debounceInformation = languageFeatureDebounceService.for(this._providers, 'Linked Editing', { max: 200 });
95
96
this._currentDecorations = this._editor.createDecorationsCollection();
97
this._languageWordPattern = null;
98
this._currentWordPattern = null;
99
this._ignoreChangeEvent = false;
100
this._localToDispose = this._register(new DisposableStore());
101
102
this._rangeUpdateTriggerPromise = null;
103
this._rangeSyncTriggerPromise = null;
104
105
this._currentRequestCts = null;
106
this._currentRequestPosition = null;
107
this._currentRequestModelVersion = null;
108
109
this._register(this._editor.onDidChangeModel(() => this.reinitialize(true)));
110
111
this._register(this._editor.onDidChangeConfiguration(e => {
112
if (e.hasChanged(EditorOption.linkedEditing) || e.hasChanged(EditorOption.renameOnType)) {
113
this.reinitialize(false);
114
}
115
}));
116
this._register(this._providers.onDidChange(() => this.reinitialize(false)));
117
this._register(this._editor.onDidChangeModelLanguage(() => this.reinitialize(true)));
118
119
this.reinitialize(true);
120
}
121
122
private reinitialize(forceRefresh: boolean) {
123
const model = this._editor.getModel();
124
const isEnabled = model !== null && (this._editor.getOption(EditorOption.linkedEditing) || this._editor.getOption(EditorOption.renameOnType)) && this._providers.has(model);
125
if (isEnabled === this._enabled && !forceRefresh) {
126
return;
127
}
128
129
this._enabled = isEnabled;
130
131
this.clearRanges();
132
this._localToDispose.clear();
133
134
if (!isEnabled || model === null) {
135
return;
136
}
137
138
this._localToDispose.add(
139
Event.runAndSubscribe(
140
model.onDidChangeLanguageConfiguration,
141
() => {
142
this._languageWordPattern = this.languageConfigurationService.getLanguageConfiguration(model.getLanguageId()).getWordDefinition();
143
}
144
)
145
);
146
147
const rangeUpdateScheduler = new Delayer(this._debounceInformation.get(model));
148
const triggerRangeUpdate = () => {
149
this._rangeUpdateTriggerPromise = rangeUpdateScheduler.trigger(() => this.updateRanges(), this._debounceDuration ?? this._debounceInformation.get(model));
150
};
151
const rangeSyncScheduler = new Delayer(0);
152
const triggerRangeSync = (token: number) => {
153
this._rangeSyncTriggerPromise = rangeSyncScheduler.trigger(() => this._syncRanges(token));
154
};
155
this._localToDispose.add(this._editor.onDidChangeCursorPosition(() => {
156
triggerRangeUpdate();
157
}));
158
this._localToDispose.add(this._editor.onDidChangeModelContent((e) => {
159
if (!this._ignoreChangeEvent) {
160
if (this._currentDecorations.length > 0) {
161
const referenceRange = this._currentDecorations.getRange(0);
162
if (referenceRange && e.changes.every(c => referenceRange.intersectRanges(c.range))) {
163
triggerRangeSync(this._syncRangesToken);
164
return;
165
}
166
}
167
}
168
triggerRangeUpdate();
169
}));
170
this._localToDispose.add({
171
dispose: () => {
172
rangeUpdateScheduler.dispose();
173
rangeSyncScheduler.dispose();
174
}
175
});
176
this.updateRanges();
177
}
178
179
private _syncRanges(token: number): void {
180
// delayed invocation, make sure we're still on
181
if (!this._editor.hasModel() || token !== this._syncRangesToken || this._currentDecorations.length === 0) {
182
// nothing to do
183
return;
184
}
185
186
const model = this._editor.getModel();
187
const referenceRange = this._currentDecorations.getRange(0);
188
189
if (!referenceRange || referenceRange.startLineNumber !== referenceRange.endLineNumber) {
190
return this.clearRanges();
191
}
192
193
const referenceValue = model.getValueInRange(referenceRange);
194
if (this._currentWordPattern) {
195
const match = referenceValue.match(this._currentWordPattern);
196
const matchLength = match ? match[0].length : 0;
197
if (matchLength !== referenceValue.length) {
198
return this.clearRanges();
199
}
200
}
201
202
const edits: ISingleEditOperation[] = [];
203
for (let i = 1, len = this._currentDecorations.length; i < len; i++) {
204
const mirrorRange = this._currentDecorations.getRange(i);
205
if (!mirrorRange) {
206
continue;
207
}
208
if (mirrorRange.startLineNumber !== mirrorRange.endLineNumber) {
209
edits.push({
210
range: mirrorRange,
211
text: referenceValue
212
});
213
} else {
214
let oldValue = model.getValueInRange(mirrorRange);
215
let newValue = referenceValue;
216
let rangeStartColumn = mirrorRange.startColumn;
217
let rangeEndColumn = mirrorRange.endColumn;
218
219
const commonPrefixLength = strings.commonPrefixLength(oldValue, newValue);
220
rangeStartColumn += commonPrefixLength;
221
oldValue = oldValue.substr(commonPrefixLength);
222
newValue = newValue.substr(commonPrefixLength);
223
224
const commonSuffixLength = strings.commonSuffixLength(oldValue, newValue);
225
rangeEndColumn -= commonSuffixLength;
226
oldValue = oldValue.substr(0, oldValue.length - commonSuffixLength);
227
newValue = newValue.substr(0, newValue.length - commonSuffixLength);
228
229
if (rangeStartColumn !== rangeEndColumn || newValue.length !== 0) {
230
edits.push({
231
range: new Range(mirrorRange.startLineNumber, rangeStartColumn, mirrorRange.endLineNumber, rangeEndColumn),
232
text: newValue
233
});
234
}
235
}
236
}
237
238
if (edits.length === 0) {
239
return;
240
}
241
242
try {
243
this._editor.popUndoStop();
244
this._ignoreChangeEvent = true;
245
const prevEditOperationType = this._editor._getViewModel().getPrevEditOperationType();
246
this._editor.executeEdits('linkedEditing', edits);
247
this._editor._getViewModel().setPrevEditOperationType(prevEditOperationType);
248
} finally {
249
this._ignoreChangeEvent = false;
250
}
251
}
252
253
public override dispose(): void {
254
this.clearRanges();
255
super.dispose();
256
}
257
258
public clearRanges(): void {
259
this._visibleContextKey.set(false);
260
this._currentDecorations.clear();
261
if (this._currentRequestCts) {
262
this._currentRequestCts.cancel();
263
this._currentRequestCts = null;
264
this._currentRequestPosition = null;
265
}
266
}
267
268
public get currentUpdateTriggerPromise(): Promise<any> {
269
return this._rangeUpdateTriggerPromise || Promise.resolve();
270
}
271
272
public get currentSyncTriggerPromise(): Promise<any> {
273
return this._rangeSyncTriggerPromise || Promise.resolve();
274
}
275
276
public async updateRanges(force = false): Promise<void> {
277
if (!this._editor.hasModel()) {
278
this.clearRanges();
279
return;
280
}
281
282
const position = this._editor.getPosition();
283
if (!this._enabled && !force || this._editor.getSelections().length > 1) {
284
// disabled or multicursor
285
this.clearRanges();
286
return;
287
}
288
289
const model = this._editor.getModel();
290
const modelVersionId = model.getVersionId();
291
if (this._currentRequestPosition && this._currentRequestModelVersion === modelVersionId) {
292
if (position.equals(this._currentRequestPosition)) {
293
return; // same position
294
}
295
if (this._currentDecorations.length > 0) {
296
const range = this._currentDecorations.getRange(0);
297
if (range && range.containsPosition(position)) {
298
return; // just moving inside the existing primary range
299
}
300
}
301
}
302
303
if (!this._currentRequestPosition?.equals(position)) {
304
// Get the current range of the first decoration (reference range)
305
const currentRange = this._currentDecorations.getRange(0);
306
// If there is no current range or the current range does not contain the new position, clear the ranges
307
if (!currentRange?.containsPosition(position)) {
308
// Clear existing decorations while we compute new ones
309
this.clearRanges();
310
}
311
}
312
313
this._currentRequestPosition = position;
314
this._currentRequestModelVersion = modelVersionId;
315
316
const currentRequestCts = this._currentRequestCts = new CancellationTokenSource();
317
try {
318
const sw = new StopWatch(false);
319
const response = await getLinkedEditingRanges(this._providers, model, position, currentRequestCts.token);
320
this._debounceInformation.update(model, sw.elapsed());
321
if (currentRequestCts !== this._currentRequestCts) {
322
return;
323
}
324
this._currentRequestCts = null;
325
if (modelVersionId !== model.getVersionId()) {
326
return;
327
}
328
329
let ranges: IRange[] = [];
330
if (response?.ranges) {
331
ranges = response.ranges;
332
}
333
334
this._currentWordPattern = response?.wordPattern || this._languageWordPattern;
335
336
let foundReferenceRange = false;
337
for (let i = 0, len = ranges.length; i < len; i++) {
338
if (Range.containsPosition(ranges[i], position)) {
339
foundReferenceRange = true;
340
if (i !== 0) {
341
const referenceRange = ranges[i];
342
ranges.splice(i, 1);
343
ranges.unshift(referenceRange);
344
}
345
break;
346
}
347
}
348
349
if (!foundReferenceRange) {
350
// Cannot do linked editing if the ranges are not where the cursor is...
351
this.clearRanges();
352
return;
353
}
354
355
const decorations: IModelDeltaDecoration[] = ranges.map(range => ({ range: range, options: LinkedEditingContribution.DECORATION }));
356
this._visibleContextKey.set(true);
357
this._currentDecorations.set(decorations);
358
this._syncRangesToken++; // cancel any pending syncRanges call
359
} catch (err) {
360
if (!isCancellationError(err)) {
361
onUnexpectedError(err);
362
}
363
if (this._currentRequestCts === currentRequestCts || !this._currentRequestCts) {
364
// stop if we are still the latest request
365
this.clearRanges();
366
}
367
}
368
369
}
370
371
// for testing
372
public setDebounceDuration(timeInMS: number) {
373
this._debounceDuration = timeInMS;
374
}
375
376
// private printDecorators(model: ITextModel) {
377
// return this._currentDecorations.map(d => {
378
// const range = model.getDecorationRange(d);
379
// if (range) {
380
// return this.printRange(range);
381
// }
382
// return 'invalid';
383
// }).join(',');
384
// }
385
386
// private printChanges(changes: IModelContentChange[]) {
387
// return changes.map(c => {
388
// return `${this.printRange(c.range)} - ${c.text}`;
389
// }
390
// ).join(',');
391
// }
392
393
// private printRange(range: IRange) {
394
// return `${range.startLineNumber},${range.startColumn}/${range.endLineNumber},${range.endColumn}`;
395
// }
396
}
397
398
export class LinkedEditingAction extends EditorAction {
399
constructor() {
400
super({
401
id: 'editor.action.linkedEditing',
402
label: nls.localize2('linkedEditing.label', "Start Linked Editing"),
403
precondition: ContextKeyExpr.and(EditorContextKeys.writable, EditorContextKeys.hasRenameProvider),
404
kbOpts: {
405
kbExpr: EditorContextKeys.editorTextFocus,
406
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.F2,
407
weight: KeybindingWeight.EditorContrib
408
}
409
});
410
}
411
412
override runCommand(accessor: ServicesAccessor, args: [URI, IPosition]): void | Promise<void> {
413
const editorService = accessor.get(ICodeEditorService);
414
const [uri, pos] = Array.isArray(args) && args || [undefined, undefined];
415
416
if (URI.isUri(uri) && Position.isIPosition(pos)) {
417
return editorService.openCodeEditor({ resource: uri }, editorService.getActiveCodeEditor()).then(editor => {
418
if (!editor) {
419
return;
420
}
421
editor.setPosition(pos);
422
editor.invokeWithinContext(accessor => {
423
this.reportTelemetry(accessor, editor);
424
return this.run(accessor, editor);
425
});
426
}, onUnexpectedError);
427
}
428
429
return super.runCommand(accessor, args);
430
}
431
432
run(_accessor: ServicesAccessor, editor: ICodeEditor): Promise<void> {
433
const controller = LinkedEditingContribution.get(editor);
434
if (controller) {
435
return Promise.resolve(controller.updateRanges(true));
436
}
437
return Promise.resolve();
438
}
439
}
440
441
const LinkedEditingCommand = EditorCommand.bindToContribution<LinkedEditingContribution>(LinkedEditingContribution.get);
442
registerEditorCommand(new LinkedEditingCommand({
443
id: 'cancelLinkedEditingInput',
444
precondition: CONTEXT_ONTYPE_RENAME_INPUT_VISIBLE,
445
handler: x => x.clearRanges(),
446
kbOpts: {
447
kbExpr: EditorContextKeys.editorTextFocus,
448
weight: KeybindingWeight.EditorContrib + 99,
449
primary: KeyCode.Escape,
450
secondary: [KeyMod.Shift | KeyCode.Escape]
451
}
452
}));
453
454
455
function getLinkedEditingRanges(providers: LanguageFeatureRegistry<LinkedEditingRangeProvider>, model: ITextModel, position: Position, token: CancellationToken): Promise<LinkedEditingRanges | undefined | null> {
456
const orderedByScore = providers.ordered(model);
457
458
// in order of score ask the linked editing range provider
459
// until someone response with a good result
460
// (good = not null)
461
return first<LinkedEditingRanges | undefined | null>(orderedByScore.map(provider => async () => {
462
try {
463
return await provider.provideLinkedEditingRanges(model, position, token);
464
} catch (e) {
465
onUnexpectedExternalError(e);
466
return undefined;
467
}
468
}), result => !!result && arrays.isNonEmptyArray(result?.ranges));
469
}
470
471
export 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.'));
472
473
registerModelAndPositionCommand('_executeLinkedEditingProvider', (_accessor, model, position) => {
474
const { linkedEditingRangeProvider } = _accessor.get(ILanguageFeaturesService);
475
return getLinkedEditingRanges(linkedEditingRangeProvider, model, position, CancellationToken.None);
476
});
477
478
registerEditorContribution(LinkedEditingContribution.ID, LinkedEditingContribution, EditorContributionInstantiation.AfterFirstRender);
479
registerEditorAction(LinkedEditingAction);
480
481