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