Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts
5281 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 { BugIndicatingError } from '../../../../../base/common/errors.js';
7
import { IObservable, ITransaction, observableSignal, observableValue } from '../../../../../base/common/observable.js';
8
import { commonPrefixLength, commonSuffixLength, splitLines } from '../../../../../base/common/strings.js';
9
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
10
import { ISingleEditOperation } from '../../../../common/core/editOperation.js';
11
import { applyEditsToRanges, StringEdit, StringReplacement } from '../../../../common/core/edits/stringEdit.js';
12
import { TextEdit, TextReplacement } from '../../../../common/core/edits/textEdit.js';
13
import { Position } from '../../../../common/core/position.js';
14
import { Range } from '../../../../common/core/range.js';
15
import { OffsetRange } from '../../../../common/core/ranges/offsetRange.js';
16
import { StringText } from '../../../../common/core/text/abstractText.js';
17
import { getPositionOffsetTransformerFromTextModel } from '../../../../common/core/text/getPositionOffsetTransformerFromTextModel.js';
18
import { PositionOffsetTransformerBase } from '../../../../common/core/text/positionToOffset.js';
19
import { TextLength } from '../../../../common/core/text/textLength.js';
20
import { linesDiffComputers } from '../../../../common/diff/linesDiffComputers.js';
21
import { Command, IInlineCompletionHint, InlineCompletion, InlineCompletionEndOfLifeReason, InlineCompletionHintStyle, InlineCompletionTriggerKind, InlineCompletionWarning, PartialAcceptInfo } from '../../../../common/languages.js';
22
import { ITextModel } from '../../../../common/model.js';
23
import { TextModelText } from '../../../../common/model/textModelText.js';
24
import { InlineCompletionViewData, InlineCompletionViewKind } from '../view/inlineEdits/inlineEditsViewInterface.js';
25
import { computeEditKind, InlineSuggestionEditKind } from './editKind.js';
26
import { inlineCompletionIsVisible } from './inlineCompletionIsVisible.js';
27
import { IInlineSuggestDataAction, IInlineSuggestDataActionEdit, InlineSuggestData, InlineSuggestionList, PartialAcceptance, RenameInfo, SnippetInfo } from './provideInlineCompletions.js';
28
import { InlineSuggestAlternativeAction } from './InlineSuggestAlternativeAction.js';
29
import { TextModelValueReference } from './textModelValueReference.js';
30
31
export type InlineSuggestionItem = InlineEditItem | InlineCompletionItem;
32
33
export namespace InlineSuggestionItem {
34
export function create(
35
data: InlineSuggestData,
36
textModel: TextModelValueReference,
37
shouldDiffEdit: boolean = true, // TODO@benibenj it should only be created once and hence not meeded to be passed here
38
): InlineSuggestionItem {
39
if (!data.isInlineEdit && !data.action?.uri && data.action?.kind === 'edit') {
40
return InlineCompletionItem.create(data, textModel, data.action);
41
} else {
42
return InlineEditItem.create(data, textModel, shouldDiffEdit);
43
}
44
}
45
}
46
47
export type InlineSuggestionAction = IInlineSuggestionActionEdit | IInlineSuggestionActionJumpTo;
48
49
export interface IInlineSuggestionActionEdit {
50
kind: 'edit';
51
textReplacement: TextReplacement;
52
snippetInfo: SnippetInfo | undefined;
53
stringEdit: StringEdit;
54
target: TextModelValueReference;
55
alternativeAction: InlineSuggestAlternativeAction | undefined;
56
}
57
58
export interface IInlineSuggestionActionJumpTo {
59
kind: 'jumpTo';
60
position: Position;
61
offset: number;
62
target: TextModelValueReference;
63
}
64
65
function hashInlineSuggestionAction(action: InlineSuggestionAction | undefined): string {
66
const obj = action?.kind === 'edit' ? {
67
...action, alternativeAction: InlineSuggestAlternativeAction.toString(action.alternativeAction),
68
target: action?.target.uri.toString(),
69
} : {
70
...action,
71
target: action?.target.uri.toString(),
72
};
73
74
return JSON.stringify(obj);
75
}
76
77
abstract class InlineSuggestionItemBase {
78
constructor(
79
protected readonly _data: InlineSuggestData,
80
public readonly identity: InlineSuggestionIdentity,
81
public readonly hint: InlineSuggestHint | undefined,
82
/**
83
* Reference to the text model this item targets.
84
* For cross-file edits, this may differ from the current editor's model.
85
*/
86
public readonly originalTextRef: TextModelValueReference,
87
) {
88
}
89
90
public abstract get action(): InlineSuggestionAction | undefined;
91
92
/**
93
* A reference to the original inline completion list this inline completion has been constructed from.
94
* Used for event data to ensure referential equality.
95
*/
96
public get source(): InlineSuggestionList { return this._data.source; }
97
98
public get isFromExplicitRequest(): boolean { return this._data.context.triggerKind === InlineCompletionTriggerKind.Explicit; }
99
public get forwardStable(): boolean { return this.source.inlineSuggestions.enableForwardStability ?? false; }
100
101
public get targetRange(): Range {
102
if (this.hint) {
103
return this.hint.range;
104
}
105
if (this.action?.kind === 'edit') {
106
return this.action.textReplacement.range;
107
} else if (this.action?.kind === 'jumpTo') {
108
return Range.fromPositions(this.action.position);
109
}
110
throw new BugIndicatingError('InlineSuggestionItem: Either hint or action must be set');
111
}
112
113
public get semanticId(): string { return this.hash; }
114
public get gutterMenuLinkAction(): Command | undefined { return this._sourceInlineCompletion.gutterMenuLinkAction; }
115
public get command(): Command | undefined { return this._sourceInlineCompletion.command; }
116
public get supportsRename(): boolean { return this._data.supportsRename; }
117
public get warning(): InlineCompletionWarning | undefined { return this._sourceInlineCompletion.warning; }
118
public get showInlineEditMenu(): boolean { return !!this._sourceInlineCompletion.showInlineEditMenu; }
119
public get hash(): string {
120
return hashInlineSuggestionAction(this.action);
121
}
122
/** @deprecated */
123
public get shownCommand(): Command | undefined { return this._sourceInlineCompletion.shownCommand; }
124
125
public get requestUuid(): string { return this._data.context.requestUuid; }
126
127
public get partialAccepts(): PartialAcceptance { return this._data.partialAccepts; }
128
129
/**
130
* A reference to the original inline completion this inline completion has been constructed from.
131
* Used for event data to ensure referential equality.
132
*/
133
private get _sourceInlineCompletion(): InlineCompletion { return this._data.sourceInlineCompletion; }
134
135
136
public abstract withEdit(userEdit: StringEdit, textModel: ITextModel): InlineSuggestionItem | undefined;
137
138
public abstract withIdentity(identity: InlineSuggestionIdentity): InlineSuggestionItem;
139
public abstract canBeReused(model: ITextModel, position: Position): boolean;
140
141
public abstract computeEditKind(model: ITextModel): InlineSuggestionEditKind | undefined;
142
143
public addRef(): void {
144
this.identity.addRef();
145
this.source.addRef();
146
}
147
148
public removeRef(): void {
149
this.identity.removeRef();
150
this.source.removeRef();
151
}
152
153
public reportInlineEditShown(commandService: ICommandService, viewKind: InlineCompletionViewKind, viewData: InlineCompletionViewData, model: ITextModel, timeWhenShown: number) {
154
const insertText = this.action?.kind === 'edit' ? this.action.textReplacement.text : ''; // TODO@hediet support insertText === undefined
155
this._data.reportInlineEditShown(commandService, insertText, viewKind, viewData, this.computeEditKind(model), timeWhenShown);
156
}
157
158
public reportPartialAccept(acceptedCharacters: number, info: PartialAcceptInfo, partialAcceptance: PartialAcceptance) {
159
this._data.reportPartialAccept(acceptedCharacters, info, partialAcceptance);
160
}
161
162
public reportEndOfLife(reason: InlineCompletionEndOfLifeReason): void {
163
this._data.reportEndOfLife(reason);
164
}
165
166
public setEndOfLifeReason(reason: InlineCompletionEndOfLifeReason): void {
167
this._data.setEndOfLifeReason(reason);
168
}
169
170
public setIsPreceeded(item: InlineSuggestionItem): void {
171
this._data.setIsPreceeded(item.partialAccepts);
172
}
173
174
public setNotShownReasonIfNotSet(reason: string): void {
175
this._data.setNotShownReason(reason);
176
}
177
178
/**
179
* Avoid using this method. Instead introduce getters for the needed properties.
180
*/
181
public getSourceCompletion(): InlineCompletion {
182
return this._sourceInlineCompletion;
183
}
184
185
public setRenameProcessingInfo(info: RenameInfo): void {
186
this._data.setRenameProcessingInfo(info);
187
}
188
189
public withAction(action: IInlineSuggestDataAction): InlineSuggestData {
190
return this._data.withAction(action);
191
}
192
193
public addPerformanceMarker(marker: string): void {
194
this._data.addPerformanceMarker(marker);
195
}
196
}
197
198
export class InlineSuggestionIdentity {
199
private static idCounter = 0;
200
private readonly _onDispose = observableSignal(this);
201
public readonly onDispose: IObservable<void> = this._onDispose;
202
203
private readonly _jumpedTo = observableValue(this, false);
204
public get jumpedTo(): IObservable<boolean> {
205
return this._jumpedTo;
206
}
207
208
private _refCount = 0;
209
public readonly id = 'InlineCompletionIdentity' + InlineSuggestionIdentity.idCounter++;
210
211
addRef() {
212
this._refCount++;
213
}
214
215
removeRef() {
216
this._refCount--;
217
if (this._refCount === 0) {
218
this._onDispose.trigger(undefined);
219
}
220
}
221
222
setJumpTo(tx: ITransaction | undefined): void {
223
this._jumpedTo.set(true, tx);
224
}
225
}
226
227
export class InlineSuggestHint {
228
229
public static create(hint: IInlineCompletionHint) {
230
return new InlineSuggestHint(
231
Range.lift(hint.range),
232
hint.content,
233
hint.style,
234
);
235
}
236
237
private constructor(
238
public readonly range: Range,
239
public readonly content: string,
240
public readonly style: InlineCompletionHintStyle,
241
) { }
242
243
public withEdit(edit: StringEdit, positionOffsetTransformer: PositionOffsetTransformerBase): InlineSuggestHint | undefined {
244
const offsetRange = new OffsetRange(
245
positionOffsetTransformer.getOffset(this.range.getStartPosition()),
246
positionOffsetTransformer.getOffset(this.range.getEndPosition())
247
);
248
249
const newOffsetRange = applyEditsToRanges([offsetRange], edit)[0];
250
if (!newOffsetRange) {
251
return undefined;
252
}
253
254
const newRange = positionOffsetTransformer.getRange(newOffsetRange);
255
256
return new InlineSuggestHint(newRange, this.content, this.style);
257
}
258
}
259
260
export class InlineCompletionItem extends InlineSuggestionItemBase {
261
public static create(
262
data: InlineSuggestData,
263
textModel: TextModelValueReference,
264
action: IInlineSuggestDataActionEdit,
265
): InlineCompletionItem {
266
const identity = new InlineSuggestionIdentity();
267
const transformer = textModel.getTransformer();
268
269
const insertText = action.insertText.replace(/\r\n|\r|\n/g, textModel.getEOL());
270
271
const edit = reshapeInlineCompletion(new StringReplacement(transformer.getOffsetRange(action.range), insertText), textModel);
272
const trimmedEdit = edit.removeCommonSuffixAndPrefix(textModel.getValue());
273
const textEdit = transformer.getTextReplacement(edit);
274
275
const displayLocation = data.hint ? InlineSuggestHint.create(data.hint) : undefined;
276
277
return new InlineCompletionItem(edit, trimmedEdit, textEdit, textEdit.range, action.snippetInfo, data.additionalTextEdits, data, identity, displayLocation, textModel);
278
}
279
280
public readonly isInlineEdit = false;
281
282
private constructor(
283
private readonly _edit: StringReplacement,
284
private readonly _trimmedEdit: StringReplacement,
285
private readonly _textEdit: TextReplacement,
286
private readonly _originalRange: Range,
287
public readonly snippetInfo: SnippetInfo | undefined,
288
public readonly additionalTextEdits: readonly ISingleEditOperation[],
289
290
data: InlineSuggestData,
291
identity: InlineSuggestionIdentity,
292
displayLocation: InlineSuggestHint | undefined,
293
originalTextRef: TextModelValueReference,
294
) {
295
super(data, identity, displayLocation, originalTextRef);
296
}
297
298
override get action(): IInlineSuggestionActionEdit {
299
return {
300
kind: 'edit',
301
textReplacement: this.getSingleTextEdit(),
302
snippetInfo: this.snippetInfo,
303
stringEdit: new StringEdit([this._trimmedEdit]),
304
alternativeAction: undefined,
305
target: this.originalTextRef,
306
};
307
}
308
309
override get hash(): string {
310
return JSON.stringify(this._trimmedEdit.toJson());
311
}
312
313
getSingleTextEdit(): TextReplacement { return this._textEdit; }
314
315
override withIdentity(identity: InlineSuggestionIdentity): InlineCompletionItem {
316
return new InlineCompletionItem(
317
this._edit,
318
this._trimmedEdit,
319
this._textEdit,
320
this._originalRange,
321
this.snippetInfo,
322
this.additionalTextEdits,
323
this._data,
324
identity,
325
this.hint,
326
this.originalTextRef
327
);
328
}
329
330
override withEdit(textModelEdit: StringEdit, textModel: ITextModel): InlineCompletionItem | undefined {
331
// If the edit is to a different model than our target, it's a noop
332
if (!this.originalTextRef.targets(textModel)) {
333
return this; // unchanged
334
}
335
336
const newEditRange = applyEditsToRanges([this._edit.replaceRange], textModelEdit);
337
if (newEditRange.length === 0) {
338
return undefined;
339
}
340
const newEdit = new StringReplacement(newEditRange[0], this._textEdit.text);
341
const positionOffsetTransformer = getPositionOffsetTransformerFromTextModel(textModel);
342
const newTextEdit = positionOffsetTransformer.getTextReplacement(newEdit);
343
344
let newDisplayLocation = this.hint;
345
if (newDisplayLocation) {
346
newDisplayLocation = newDisplayLocation.withEdit(textModelEdit, positionOffsetTransformer);
347
if (!newDisplayLocation) {
348
return undefined;
349
}
350
}
351
352
const trimmedEdit = newEdit.removeCommonSuffixAndPrefix(textModel.getValue());
353
354
return new InlineCompletionItem(
355
newEdit,
356
trimmedEdit,
357
newTextEdit,
358
this._originalRange,
359
this.snippetInfo,
360
this.additionalTextEdits,
361
this._data,
362
this.identity,
363
newDisplayLocation,
364
this.originalTextRef
365
);
366
}
367
368
override canBeReused(model: ITextModel, position: Position): boolean {
369
// TODO@hediet I believe this can be simplified to `return true;`, as applying an edit should kick out this suggestion.
370
const updatedRange = this._textEdit.range;
371
const result = !!updatedRange
372
&& updatedRange.containsPosition(position)
373
&& this.isVisible(model, position)
374
&& TextLength.ofRange(updatedRange).isGreaterThanOrEqualTo(TextLength.ofRange(this._originalRange));
375
return result;
376
}
377
378
public isVisible(model: ITextModel, cursorPosition: Position): boolean {
379
const singleTextEdit = this.getSingleTextEdit();
380
return inlineCompletionIsVisible(singleTextEdit, this._originalRange, model, cursorPosition);
381
}
382
383
override computeEditKind(model: ITextModel): InlineSuggestionEditKind | undefined {
384
return computeEditKind(new StringEdit([this._edit]), model);
385
}
386
387
public get editRange(): Range { return this.getSingleTextEdit().range; }
388
public get insertText(): string { return this.getSingleTextEdit().text; }
389
}
390
391
export class InlineEditItem extends InlineSuggestionItemBase {
392
public static createForTest(
393
textModel: TextModelValueReference,
394
range: Range,
395
newText: string,
396
): InlineEditItem {
397
const action: IInlineSuggestDataAction = {
398
kind: 'edit',
399
snippetInfo: undefined,
400
insertText: newText,
401
range: range,
402
uri: textModel.uri,
403
alternativeAction: undefined,
404
};
405
406
return InlineEditItem.create(InlineSuggestData.createForTest(action, textModel.uri), textModel);
407
}
408
409
public static create(
410
data: InlineSuggestData,
411
textModel: TextModelValueReference,
412
shouldDiffEdit: boolean = true,
413
): InlineEditItem {
414
let action: InlineSuggestionAction | undefined;
415
let edits: SingleUpdatedNextEdit[] = [];
416
if (data.action?.kind === 'edit') {
417
const offsetEdit = shouldDiffEdit ? getDiffedStringEdit(textModel, data.action.range, data.action.insertText) : getStringEdit(textModel, data.action.range, data.action.insertText); // TODO compute async
418
const textEdit = TextEdit.fromStringEdit(offsetEdit, textModel);
419
const singleTextEdit = offsetEdit.isEmpty() ? new TextReplacement(new Range(1, 1, 1, 1), '') : textEdit.toReplacement(textModel); // FIXME: .toReplacement() can throw because offsetEdit is empty because we get an empty diff in getStringEdit after diffing
420
421
edits = offsetEdit.replacements.map(edit => {
422
const replacedRange = Range.fromPositions(textModel.getPositionAt(edit.replaceRange.start), textModel.getTransformer().getPosition(edit.replaceRange.endExclusive));
423
const replacedText = textModel.getValueInRange(replacedRange);
424
return SingleUpdatedNextEdit.create(edit, replacedText);
425
});
426
427
action = {
428
kind: 'edit',
429
snippetInfo: data.action.snippetInfo,
430
stringEdit: offsetEdit,
431
textReplacement: singleTextEdit,
432
alternativeAction: data.action.alternativeAction,
433
target: textModel,
434
};
435
} else if (data.action?.kind === 'jumpTo') {
436
action = {
437
kind: 'jumpTo',
438
position: data.action.position,
439
offset: textModel.getTransformer().getOffset(data.action.position),
440
target: textModel,
441
};
442
} else {
443
action = undefined;
444
if (!data.hint) {
445
throw new BugIndicatingError('InlineEditItem: action is undefined and no hint is provided');
446
}
447
}
448
449
const identity = new InlineSuggestionIdentity();
450
451
const hint = data.hint ? InlineSuggestHint.create(data.hint) : undefined;
452
return new InlineEditItem(action, data, identity, edits, hint, false, textModel.getVersionId(), textModel);
453
}
454
455
public readonly snippetInfo: SnippetInfo | undefined = undefined;
456
public readonly additionalTextEdits: readonly ISingleEditOperation[] = [];
457
public readonly isInlineEdit = true;
458
459
private constructor(
460
private readonly _action: InlineSuggestionAction | undefined,
461
462
data: InlineSuggestData,
463
464
identity: InlineSuggestionIdentity,
465
private readonly _edits: readonly SingleUpdatedNextEdit[],
466
hint: InlineSuggestHint | undefined,
467
private readonly _lastChangePartOfInlineEdit = false,
468
private readonly _inlineEditModelVersion: number,
469
originalTextRef: TextModelValueReference,
470
) {
471
super(data, identity, hint, originalTextRef);
472
}
473
474
public get updatedEditModelVersion(): number { return this._inlineEditModelVersion; }
475
// public get updatedEdit(): StringEdit { return this._edit; }
476
477
override get action(): InlineSuggestionAction | undefined {
478
return this._action;
479
}
480
481
override withIdentity(identity: InlineSuggestionIdentity): InlineEditItem {
482
return new InlineEditItem(
483
this._action,
484
this._data,
485
identity,
486
this._edits,
487
this.hint,
488
this._lastChangePartOfInlineEdit,
489
this._inlineEditModelVersion,
490
this.originalTextRef,
491
);
492
}
493
494
override canBeReused(model: ITextModel, position: Position): boolean {
495
// TODO@hediet I believe this can be simplified to `return true;`, as applying an edit should kick out this suggestion.
496
return this._lastChangePartOfInlineEdit && this.updatedEditModelVersion === model.getVersionId();
497
}
498
499
override withEdit(textModelChanges: StringEdit, textModel: ITextModel): InlineEditItem | undefined {
500
// If the edit is to a different model than our target, it's a noop
501
if (!this.originalTextRef.targets(textModel)) {
502
return this; // unchanged
503
}
504
505
const edit = this._applyTextModelChanges(textModelChanges, this._edits, textModel);
506
return edit;
507
}
508
509
private _applyTextModelChanges(textModelChanges: StringEdit, edits: readonly SingleUpdatedNextEdit[], textModel: ITextModel): InlineEditItem | undefined {
510
const positionOffsetTransformer = getPositionOffsetTransformerFromTextModel(textModel);
511
512
let lastChangePartOfInlineEdit = false;
513
let inlineEditModelVersion = this._inlineEditModelVersion;
514
let newAction: InlineSuggestionAction | undefined;
515
516
if (this.action?.kind === 'edit') { // TODO What about rename?
517
edits = edits.map(innerEdit => innerEdit.applyTextModelChanges(textModelChanges));
518
519
if (edits.some(edit => edit.edit === undefined)) {
520
return undefined; // change is invalid, so we will have to drop the completion
521
}
522
523
524
const newTextModelVersion = textModel.getVersionId();
525
lastChangePartOfInlineEdit = edits.some(edit => edit.lastChangeUpdatedEdit);
526
if (lastChangePartOfInlineEdit) {
527
inlineEditModelVersion = newTextModelVersion ?? -1;
528
}
529
530
if (newTextModelVersion === null || inlineEditModelVersion + 20 < newTextModelVersion) {
531
return undefined; // the completion has been ignored for a while, remove it
532
}
533
534
edits = edits.filter(innerEdit => !innerEdit.edit!.isEmpty);
535
if (edits.length === 0) {
536
return undefined; // the completion has been typed by the user
537
}
538
539
const newEdit = new StringEdit(edits.map(edit => edit.edit!));
540
541
const newTextEdit = positionOffsetTransformer.getTextEdit(newEdit).toReplacement(new TextModelText(textModel));
542
543
newAction = {
544
kind: 'edit',
545
textReplacement: newTextEdit,
546
snippetInfo: this.snippetInfo,
547
stringEdit: newEdit,
548
alternativeAction: this.action.alternativeAction,
549
target: this.originalTextRef,
550
};
551
} else if (this.action?.kind === 'jumpTo') {
552
const jumpToOffset = this.action.offset;
553
const newJumpToOffset = textModelChanges.applyToOffsetOrUndefined(jumpToOffset);
554
if (newJumpToOffset === undefined) {
555
return undefined;
556
}
557
const newJumpToPosition = positionOffsetTransformer.getPosition(newJumpToOffset);
558
559
newAction = {
560
kind: 'jumpTo',
561
position: newJumpToPosition,
562
offset: newJumpToOffset,
563
target: this.originalTextRef,
564
};
565
} else {
566
newAction = undefined;
567
}
568
569
let newDisplayLocation = this.hint;
570
if (newDisplayLocation) {
571
newDisplayLocation = newDisplayLocation.withEdit(textModelChanges, positionOffsetTransformer);
572
if (!newDisplayLocation) {
573
return undefined;
574
}
575
}
576
577
return new InlineEditItem(
578
newAction,
579
this._data,
580
this.identity,
581
edits,
582
newDisplayLocation,
583
lastChangePartOfInlineEdit,
584
inlineEditModelVersion,
585
this.originalTextRef,
586
);
587
}
588
589
override computeEditKind(model: ITextModel): InlineSuggestionEditKind | undefined {
590
const edit = this.action?.kind === 'edit' ? this.action.stringEdit : undefined;
591
if (!edit) {
592
return undefined;
593
}
594
return computeEditKind(edit, model);
595
}
596
}
597
598
function getDiffedStringEdit(textModel: TextModelValueReference, editRange: Range, replaceText: string): StringEdit {
599
const eol = textModel.getEOL();
600
const editOriginalText = textModel.getValueOfRange(editRange);
601
const editReplaceText = replaceText.replace(/\r\n|\r|\n/g, eol);
602
603
const diffAlgorithm = linesDiffComputers.getDefault();
604
const lineDiffs = diffAlgorithm.computeDiff(
605
splitLines(editOriginalText),
606
splitLines(editReplaceText),
607
{
608
ignoreTrimWhitespace: false,
609
computeMoves: false,
610
extendToSubwords: true,
611
maxComputationTimeMs: 50,
612
}
613
);
614
615
const innerChanges = lineDiffs.changes.flatMap(c => c.innerChanges ?? []);
616
617
function addRangeToPos(pos: Position, range: Range): Range {
618
const start = TextLength.fromPosition(range.getStartPosition());
619
return TextLength.ofRange(range).createRange(start.addToPosition(pos));
620
}
621
622
const modifiedText = new StringText(editReplaceText);
623
624
const offsetEdit = new StringEdit(
625
innerChanges.map(c => {
626
const rangeInModel = addRangeToPos(editRange.getStartPosition(), c.originalRange);
627
const originalRange = textModel.getTransformer().getOffsetRange(rangeInModel);
628
629
const replaceText = modifiedText.getValueOfRange(c.modifiedRange);
630
const edit = new StringReplacement(originalRange, replaceText);
631
632
const originalText = textModel.getValueOfRange(rangeInModel);
633
return reshapeInlineEdit(edit, originalText, innerChanges.length, textModel);
634
})
635
);
636
637
return offsetEdit;
638
}
639
640
function getStringEdit(textModel: TextModelValueReference, editRange: Range, replaceText: string): StringEdit {
641
return new StringEdit([new StringReplacement(
642
textModel.getTransformer().getOffsetRange(editRange),
643
replaceText
644
)]);
645
}
646
647
class SingleUpdatedNextEdit {
648
public static create(
649
edit: StringReplacement,
650
replacedText: string,
651
): SingleUpdatedNextEdit {
652
const prefixLength = commonPrefixLength(edit.newText, replacedText);
653
const suffixLength = commonSuffixLength(edit.newText, replacedText);
654
const trimmedNewText = edit.newText.substring(prefixLength, edit.newText.length - suffixLength);
655
return new SingleUpdatedNextEdit(edit, trimmedNewText, prefixLength, suffixLength);
656
}
657
658
public get edit() { return this._edit; }
659
public get lastChangeUpdatedEdit() { return this._lastChangeUpdatedEdit; }
660
661
constructor(
662
private _edit: StringReplacement | undefined,
663
private _trimmedNewText: string,
664
private _prefixLength: number,
665
private _suffixLength: number,
666
private _lastChangeUpdatedEdit: boolean = false,
667
) {
668
}
669
670
public applyTextModelChanges(textModelChanges: StringEdit) {
671
const c = this._clone();
672
c._applyTextModelChanges(textModelChanges);
673
return c;
674
}
675
676
private _clone(): SingleUpdatedNextEdit {
677
return new SingleUpdatedNextEdit(
678
this._edit,
679
this._trimmedNewText,
680
this._prefixLength,
681
this._suffixLength,
682
this._lastChangeUpdatedEdit,
683
);
684
}
685
686
private _applyTextModelChanges(textModelChanges: StringEdit) {
687
this._lastChangeUpdatedEdit = false; // TODO @benibenj make immutable
688
689
if (!this._edit) {
690
throw new BugIndicatingError('UpdatedInnerEdits: No edit to apply changes to');
691
}
692
693
const result = this._applyChanges(this._edit, textModelChanges);
694
if (!result) {
695
this._edit = undefined;
696
return;
697
}
698
699
this._edit = result.edit;
700
this._lastChangeUpdatedEdit = result.editHasChanged;
701
}
702
703
private _applyChanges(edit: StringReplacement, textModelChanges: StringEdit): { edit: StringReplacement; editHasChanged: boolean } | undefined {
704
let editStart = edit.replaceRange.start;
705
let editEnd = edit.replaceRange.endExclusive;
706
let editReplaceText = edit.newText;
707
let editHasChanged = false;
708
709
const shouldPreserveEditShape = this._prefixLength > 0 || this._suffixLength > 0;
710
711
for (let i = textModelChanges.replacements.length - 1; i >= 0; i--) {
712
const change = textModelChanges.replacements[i];
713
714
// INSERTIONS (only support inserting at start of edit)
715
const isInsertion = change.newText.length > 0 && change.replaceRange.isEmpty;
716
717
if (isInsertion && !shouldPreserveEditShape && change.replaceRange.start === editStart && editReplaceText.startsWith(change.newText)) {
718
editStart += change.newText.length;
719
editReplaceText = editReplaceText.substring(change.newText.length);
720
editEnd += change.newText.length;
721
editHasChanged = true;
722
continue;
723
}
724
725
if (isInsertion && shouldPreserveEditShape && change.replaceRange.start === editStart + this._prefixLength && this._trimmedNewText.startsWith(change.newText)) {
726
editEnd += change.newText.length;
727
editHasChanged = true;
728
this._prefixLength += change.newText.length;
729
this._trimmedNewText = this._trimmedNewText.substring(change.newText.length);
730
continue;
731
}
732
733
// DELETIONS
734
const isDeletion = change.newText.length === 0 && change.replaceRange.length > 0;
735
if (isDeletion && change.replaceRange.start >= editStart + this._prefixLength && change.replaceRange.endExclusive <= editEnd - this._suffixLength) {
736
// user deleted text IN-BETWEEN the deletion range
737
editEnd -= change.replaceRange.length;
738
editHasChanged = true;
739
continue;
740
}
741
742
// user did exactly the edit
743
if (change.equals(edit)) {
744
editHasChanged = true;
745
editStart = change.replaceRange.endExclusive;
746
editReplaceText = '';
747
continue;
748
}
749
750
// MOVE EDIT
751
if (change.replaceRange.start > editEnd) {
752
// the change happens after the completion range
753
continue;
754
}
755
if (change.replaceRange.endExclusive < editStart) {
756
// the change happens before the completion range
757
editStart += change.newText.length - change.replaceRange.length;
758
editEnd += change.newText.length - change.replaceRange.length;
759
continue;
760
}
761
762
// The change intersects the completion, so we will have to drop the completion
763
return undefined;
764
}
765
766
// the resulting edit is a noop as the original and new text are the same
767
if (this._trimmedNewText.length === 0 && editStart + this._prefixLength === editEnd - this._suffixLength) {
768
return { edit: new StringReplacement(new OffsetRange(editStart + this._prefixLength, editStart + this._prefixLength), ''), editHasChanged: true };
769
}
770
771
return { edit: new StringReplacement(new OffsetRange(editStart, editEnd), editReplaceText), editHasChanged };
772
}
773
}
774
775
function reshapeInlineCompletion(edit: StringReplacement, textModel: TextModelValueReference): StringReplacement {
776
// If the insertion is a multi line insertion starting on the next line
777
// Move it forwards so that the multi line insertion starts on the current line
778
const eol = textModel.getEOL();
779
if (edit.replaceRange.isEmpty && edit.newText.includes(eol)) {
780
edit = reshapeMultiLineInsertion(edit, textModel);
781
}
782
783
return edit;
784
}
785
786
function reshapeInlineEdit(edit: StringReplacement, originalText: string, totalInnerEdits: number, textModel: TextModelValueReference): StringReplacement {
787
// TODO: EOL are not properly trimmed by the diffAlgorithm #12680
788
const eol = textModel.getEOL();
789
if (edit.newText.endsWith(eol) && originalText.endsWith(eol)) {
790
edit = new StringReplacement(edit.replaceRange.deltaEnd(-eol.length), edit.newText.slice(0, -eol.length));
791
}
792
793
// INSERTION
794
// If the insertion ends with a new line and is inserted at the start of a line which has text,
795
// we move the insertion to the end of the previous line if possible
796
if (totalInnerEdits === 1 && edit.replaceRange.isEmpty && edit.newText.includes(eol)) {
797
const startPosition = textModel.getTransformer().getPosition(edit.replaceRange.start);
798
const hasTextOnInsertionLine = textModel.getLineLength(startPosition.lineNumber) !== 0;
799
if (hasTextOnInsertionLine) {
800
edit = reshapeMultiLineInsertion(edit, textModel);
801
}
802
}
803
804
// The diff algorithm extended a simple edit to the entire word
805
// shrink it back to a simple edit if it is deletion/insertion only
806
if (totalInnerEdits === 1) {
807
const prefixLength = commonPrefixLength(originalText, edit.newText);
808
const suffixLength = commonSuffixLength(originalText.slice(prefixLength), edit.newText.slice(prefixLength));
809
810
// reshape it back to an insertion
811
if (prefixLength + suffixLength === originalText.length) {
812
return new StringReplacement(edit.replaceRange.deltaStart(prefixLength).deltaEnd(-suffixLength), edit.newText.substring(prefixLength, edit.newText.length - suffixLength));
813
}
814
815
// reshape it back to a deletion
816
if (prefixLength + suffixLength === edit.newText.length) {
817
return new StringReplacement(edit.replaceRange.deltaStart(prefixLength).deltaEnd(-suffixLength), '');
818
}
819
}
820
821
return edit;
822
}
823
824
function reshapeMultiLineInsertion(edit: StringReplacement, textModel: TextModelValueReference): StringReplacement {
825
if (!edit.replaceRange.isEmpty) {
826
throw new BugIndicatingError('Unexpected original range');
827
}
828
829
if (edit.replaceRange.start === 0) {
830
return edit;
831
}
832
833
const eol = textModel.getEOL();
834
const startPosition = textModel.getTransformer().getPosition(edit.replaceRange.start);
835
const startColumn = startPosition.column;
836
const startLineNumber = startPosition.lineNumber;
837
838
// If the insertion ends with a new line and is inserted at the start of a line which has text,
839
// we move the insertion to the end of the previous line if possible
840
if (startColumn === 1 && startLineNumber > 1 && edit.newText.endsWith(eol) && !edit.newText.startsWith(eol)) {
841
return new StringReplacement(edit.replaceRange.delta(-1), eol + edit.newText.slice(0, -eol.length));
842
}
843
844
return edit;
845
}
846
847