Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.ts
5222 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 { addDisposableListener, getWindow } from '../../../../../base/browser/dom.js';
7
import { assert } from '../../../../../base/common/assert.js';
8
import { DeferredPromise, RunOnceScheduler, timeout } from '../../../../../base/common/async.js';
9
import { Emitter } from '../../../../../base/common/event.js';
10
import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js';
11
import { autorun, IObservable, observableValue } from '../../../../../base/common/observable.js';
12
import { isEqual } from '../../../../../base/common/resources.js';
13
import { themeColorFromId } from '../../../../../base/common/themables.js';
14
import { assertType } from '../../../../../base/common/types.js';
15
import { URI } from '../../../../../base/common/uri.js';
16
import { EditOperation, ISingleEditOperation } from '../../../../../editor/common/core/editOperation.js';
17
import { StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js';
18
import { IRange, Range } from '../../../../../editor/common/core/range.js';
19
import { LineRange } from '../../../../../editor/common/core/ranges/lineRange.js';
20
import { IDocumentDiff, nullDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js';
21
import { DetailedLineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js';
22
import { TextEdit, VersionedExtensionId } from '../../../../../editor/common/languages.js';
23
import { IModelDeltaDecoration, ITextModel, ITextSnapshot, MinimapPosition, OverviewRulerLane } from '../../../../../editor/common/model.js';
24
import { ModelDecorationOptions } from '../../../../../editor/common/model/textModel.js';
25
import { offsetEditFromContentChanges, offsetEditFromLineRangeMapping, offsetEditToEditOperations } from '../../../../../editor/common/model/textModelStringEdit.js';
26
import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js';
27
import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js';
28
import { IModelContentChangedEvent } from '../../../../../editor/common/textModelEvents.js';
29
import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
30
import { editorSelectionBackground } from '../../../../../platform/theme/common/colorRegistry.js';
31
import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js';
32
import { ModifiedFileEntryState } from '../../common/editing/chatEditingService.js';
33
import { IChatResponseModel } from '../../common/model/chatModel.js';
34
import { ChatAgentLocation } from '../../common/constants.js';
35
import { IDocumentDiff2 } from './chatEditingCodeEditorIntegration.js';
36
import { pendingRewriteMinimap } from './chatEditingModifiedFileEntry.js';
37
38
type affectedLines = { linesAdded: number; linesRemoved: number; lineCount: number; hasRemainingEdits: boolean };
39
type acceptedOrRejectedLines = affectedLines & { state: 'accepted' | 'rejected' };
40
41
export class ChatEditingTextModelChangeService extends Disposable {
42
43
private static readonly _lastEditDecorationOptions = ModelDecorationOptions.register({
44
isWholeLine: true,
45
description: 'chat-last-edit',
46
className: 'chat-editing-last-edit-line',
47
marginClassName: 'chat-editing-last-edit',
48
overviewRuler: {
49
position: OverviewRulerLane.Full,
50
color: themeColorFromId(editorSelectionBackground)
51
},
52
});
53
54
private static readonly _pendingEditDecorationOptions = ModelDecorationOptions.register({
55
isWholeLine: true,
56
description: 'chat-pending-edit',
57
className: 'chat-editing-pending-edit',
58
minimap: {
59
position: MinimapPosition.Inline,
60
color: themeColorFromId(pendingRewriteMinimap)
61
}
62
});
63
64
private static readonly _atomicEditDecorationOptions = ModelDecorationOptions.register({
65
isWholeLine: true,
66
description: 'chat-atomic-edit',
67
className: 'chat-editing-atomic-edit',
68
minimap: {
69
position: MinimapPosition.Inline,
70
color: themeColorFromId(pendingRewriteMinimap)
71
}
72
});
73
74
private _isEditFromUs: boolean = false;
75
public get isEditFromUs() {
76
return this._isEditFromUs;
77
}
78
private _allEditsAreFromUs: boolean = true;
79
public get allEditsAreFromUs() {
80
return this._allEditsAreFromUs;
81
}
82
private _isExternalEditInProgress: (() => boolean) | undefined;
83
private _diffOperation: Promise<IDocumentDiff | undefined> | undefined;
84
private _diffOperationIds: number = 0;
85
86
private readonly _diffInfo = observableValue<IDocumentDiff>(this, nullDocumentDiff);
87
public get diffInfo() {
88
return this._diffInfo.map(value => {
89
return {
90
...value,
91
originalModel: this.originalModel,
92
modifiedModel: this.modifiedModel,
93
keep: changes => this._keepHunk(changes),
94
undo: changes => this._undoHunk(changes)
95
} satisfies IDocumentDiff2;
96
});
97
}
98
99
private readonly _editDecorationClear = this._register(new RunOnceScheduler(() => { this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, []); }, 500));
100
private _editDecorations: string[] = [];
101
102
private readonly _didAcceptOrRejectAllHunks = this._register(new Emitter<ModifiedFileEntryState.Accepted | ModifiedFileEntryState.Rejected>());
103
public readonly onDidAcceptOrRejectAllHunks = this._didAcceptOrRejectAllHunks.event;
104
105
private readonly _didAcceptOrRejectLines = this._register(new Emitter<acceptedOrRejectedLines>());
106
public readonly onDidAcceptOrRejectLines = this._didAcceptOrRejectLines.event;
107
108
private notifyHunkAction(state: 'accepted' | 'rejected', affectedLines: affectedLines) {
109
if (affectedLines.lineCount > 0) {
110
this._didAcceptOrRejectLines.fire({ state, ...affectedLines });
111
}
112
}
113
114
private _didUserEditModelFired = false;
115
private readonly _didUserEditModel = this._register(new Emitter<void>());
116
public readonly onDidUserEditModel = this._didUserEditModel.event;
117
118
private _originalToModifiedEdit: StringEdit = StringEdit.empty;
119
120
private lineChangeCount: number = 0;
121
private linesAdded: number = 0;
122
private linesRemoved: number = 0;
123
124
constructor(
125
private readonly originalModel: ITextModel,
126
private readonly modifiedModel: ITextModel,
127
private readonly state: IObservable<ModifiedFileEntryState>,
128
isExternalEditInProgress: (() => boolean) | undefined,
129
@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,
130
@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService,
131
) {
132
super();
133
this._isExternalEditInProgress = isExternalEditInProgress;
134
this._register(this.modifiedModel.onDidChangeContent(e => {
135
this._mirrorEdits(e);
136
}));
137
138
this._register(toDisposable(() => {
139
this.clearCurrentEditLineDecoration();
140
}));
141
142
this._register(autorun(r => this.updateLineChangeCount(this._diffInfo.read(r))));
143
144
if (!originalModel.equalsTextBuffer(modifiedModel.getTextBuffer())) {
145
this._updateDiffInfoSeq();
146
}
147
}
148
149
private updateLineChangeCount(diff: IDocumentDiff) {
150
this.lineChangeCount = 0;
151
this.linesAdded = 0;
152
this.linesRemoved = 0;
153
154
for (const change of diff.changes) {
155
const modifiedRange = change.modified.endLineNumberExclusive - change.modified.startLineNumber;
156
this.linesAdded += Math.max(0, modifiedRange);
157
const originalRange = change.original.endLineNumberExclusive - change.original.startLineNumber;
158
this.linesRemoved += Math.max(0, originalRange);
159
160
this.lineChangeCount += Math.max(modifiedRange, originalRange);
161
}
162
}
163
164
public clearCurrentEditLineDecoration() {
165
if (!this.modifiedModel.isDisposed()) {
166
this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, []);
167
}
168
}
169
170
public async areOriginalAndModifiedIdentical(): Promise<boolean> {
171
const diff = await this._diffOperation;
172
return diff ? diff.identical : false;
173
}
174
175
async acceptAgentEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel | undefined): Promise<{ rewriteRatio: number; maxLineNumber: number }> {
176
177
assertType(textEdits.every(TextEdit.isTextEdit), 'INVALID args, can only handle text edits');
178
assert(isEqual(resource, this.modifiedModel.uri), ' INVALID args, can only edit THIS document');
179
180
const isAtomicEdits = textEdits.length > 0 && isLastEdits;
181
let maxLineNumber = 0;
182
let rewriteRatio = 0;
183
184
const source = this._createEditSource(responseModel);
185
186
if (isAtomicEdits) {
187
// EDIT and DONE
188
const minimalEdits = await this._editorWorkerService.computeMoreMinimalEdits(this.modifiedModel.uri, textEdits) ?? textEdits;
189
const ops = minimalEdits.map(TextEdit.asEditOperation);
190
const undoEdits = this._applyEdits(ops, source);
191
192
if (undoEdits.length > 0) {
193
let range: Range | undefined;
194
for (let i = 0; i < undoEdits.length; i++) {
195
const op = undoEdits[i];
196
if (!range) {
197
range = Range.lift(op.range);
198
} else {
199
range = Range.plusRange(range, op.range);
200
}
201
}
202
if (range) {
203
204
const defer = new DeferredPromise<void>();
205
const listener = addDisposableListener(getWindow(undefined), 'animationend', e => {
206
if (e.animationName === 'kf-chat-editing-atomic-edit') { // CHECK with chat.css
207
defer.complete();
208
listener.dispose();
209
}
210
});
211
212
this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, [{
213
options: ChatEditingTextModelChangeService._atomicEditDecorationOptions,
214
range
215
}]);
216
217
await Promise.any([defer.p, timeout(500)]); // wait for animation to finish but also time-cap it
218
listener.dispose();
219
}
220
}
221
222
223
} else {
224
// EDIT a bit, then DONE
225
const ops = textEdits.map(TextEdit.asEditOperation);
226
const undoEdits = this._applyEdits(ops, source);
227
maxLineNumber = undoEdits.reduce((max, op) => Math.max(max, op.range.startLineNumber), 0);
228
rewriteRatio = Math.min(1, maxLineNumber / this.modifiedModel.getLineCount());
229
230
const newDecorations: IModelDeltaDecoration[] = [
231
// decorate pending edit (region)
232
{
233
options: ChatEditingTextModelChangeService._pendingEditDecorationOptions,
234
range: new Range(maxLineNumber + 1, 1, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)
235
}
236
];
237
238
if (maxLineNumber > 0) {
239
// decorate last edit
240
newDecorations.push({
241
options: ChatEditingTextModelChangeService._lastEditDecorationOptions,
242
range: new Range(maxLineNumber, 1, maxLineNumber, Number.MAX_SAFE_INTEGER)
243
});
244
}
245
this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, newDecorations);
246
247
}
248
249
if (isLastEdits) {
250
this._updateDiffInfoSeq();
251
this._editDecorationClear.schedule();
252
}
253
254
return { rewriteRatio, maxLineNumber };
255
}
256
257
private _createEditSource(responseModel: IChatResponseModel | undefined) {
258
259
if (!responseModel) {
260
return EditSources.unknown({ name: 'editSessionUndoRedo' });
261
}
262
263
const sessionId = responseModel.session.sessionId;
264
const request = responseModel.session.getRequests().at(-1);
265
const languageId = this.modifiedModel.getLanguageId();
266
const agent = responseModel.agent;
267
const extensionId = VersionedExtensionId.tryCreate(agent?.extensionId.value, agent?.extensionVersion);
268
269
if (responseModel.request?.locationData?.type === ChatAgentLocation.EditorInline) {
270
271
return EditSources.inlineChatApplyEdit({
272
modelId: request?.modelId,
273
requestId: request?.id,
274
sessionId,
275
languageId,
276
extensionId,
277
});
278
}
279
280
return EditSources.chatApplyEdits({
281
modelId: request?.modelId,
282
requestId: request?.id,
283
sessionId,
284
languageId,
285
mode: request?.modeInfo?.modeId,
286
extensionId,
287
codeBlockSuggestionId: request?.modeInfo?.applyCodeBlockSuggestionId,
288
});
289
}
290
291
private _applyEdits(edits: ISingleEditOperation[], source: TextModelEditSource) {
292
293
if (edits.length === 0) {
294
return [];
295
}
296
297
try {
298
this._isEditFromUs = true;
299
// make the actual edit
300
let result: ISingleEditOperation[] = [];
301
302
this.modifiedModel.pushEditOperations(null, edits, (undoEdits) => {
303
result = undoEdits;
304
return null;
305
}, undefined, source);
306
307
return result;
308
} finally {
309
this._isEditFromUs = false;
310
}
311
}
312
313
/**
314
* Keeps the current modified document as the final contents.
315
*/
316
public keep() {
317
this.notifyHunkAction('accepted', { linesAdded: this.linesAdded, linesRemoved: this.linesRemoved, lineCount: this.lineChangeCount, hasRemainingEdits: false });
318
this.originalModel.setValue(this.modifiedModel.createSnapshot());
319
this._reset();
320
}
321
322
/**
323
* Undoes the current modified document as the final contents.
324
*/
325
public undo() {
326
this.notifyHunkAction('rejected', { linesAdded: this.linesAdded, linesRemoved: this.linesRemoved, lineCount: this.lineChangeCount, hasRemainingEdits: false });
327
this.modifiedModel.pushStackElement();
328
this._applyEdits([(EditOperation.replace(this.modifiedModel.getFullModelRange(), this.originalModel.getValue()))], EditSources.chatUndoEdits());
329
this.modifiedModel.pushStackElement();
330
this._reset();
331
}
332
333
private _reset() {
334
this._originalToModifiedEdit = StringEdit.empty;
335
this._diffInfo.set(nullDocumentDiff, undefined);
336
this._didUserEditModelFired = false;
337
}
338
339
public async resetDocumentValues(newOriginal: string | ITextSnapshot | undefined, newModified: string | undefined): Promise<void> {
340
let didChange = false;
341
if (newOriginal !== undefined) {
342
this.originalModel.setValue(newOriginal);
343
didChange = true;
344
}
345
if (newModified !== undefined && this.modifiedModel.getValue() !== newModified) {
346
// NOTE that this isn't done via `setValue` so that the undo stack is preserved
347
this.modifiedModel.pushStackElement();
348
this._applyEdits([(EditOperation.replace(this.modifiedModel.getFullModelRange(), newModified))], EditSources.chatReset());
349
this.modifiedModel.pushStackElement();
350
didChange = true;
351
}
352
if (didChange) {
353
await this._updateDiffInfoSeq();
354
}
355
}
356
357
private _mirrorEdits(event: IModelContentChangedEvent) {
358
const edit = offsetEditFromContentChanges(event.changes);
359
const isExternalEdit = this._isExternalEditInProgress?.();
360
361
if (this._isEditFromUs || isExternalEdit) {
362
const e_sum = this._originalToModifiedEdit;
363
const e_ai = edit;
364
this._originalToModifiedEdit = e_sum.compose(e_ai);
365
if (isExternalEdit) {
366
this._updateDiffInfoSeq();
367
}
368
} else {
369
370
// e_ai
371
// d0 ---------------> s0
372
// | |
373
// | |
374
// | e_user_r | e_user
375
// | |
376
// | |
377
// v e_ai_r v
378
/// d1 ---------------> s1
379
//
380
// d0 - document snapshot
381
// s0 - document
382
// e_ai - ai edits
383
// e_user - user edits
384
//
385
const e_ai = this._originalToModifiedEdit;
386
const e_user = edit;
387
388
const e_user_r = e_user.tryRebase(e_ai.inverse(this.originalModel.getValue()));
389
390
if (e_user_r === undefined) {
391
// user edits overlaps/conflicts with AI edits
392
this._originalToModifiedEdit = e_ai.compose(e_user);
393
} else {
394
const edits = offsetEditToEditOperations(e_user_r, this.originalModel);
395
this.originalModel.applyEdits(edits);
396
this._originalToModifiedEdit = e_ai.rebaseSkipConflicting(e_user_r);
397
}
398
399
this._allEditsAreFromUs = false;
400
this._updateDiffInfoSeq();
401
if (!this._didUserEditModelFired) {
402
this._didUserEditModelFired = true;
403
this._didUserEditModel.fire();
404
}
405
}
406
}
407
408
private async _keepHunk(change: DetailedLineRangeMapping): Promise<boolean> {
409
if (!this._diffInfo.get().changes.includes(change)) {
410
// diffInfo should have model version ids and check them (instead of the caller doing that)
411
return false;
412
}
413
const edits: ISingleEditOperation[] = [];
414
for (const edit of change.innerChanges ?? []) {
415
const newText = this.modifiedModel.getValueInRange(edit.modifiedRange);
416
edits.push(EditOperation.replace(edit.originalRange, newText));
417
}
418
this.originalModel.pushEditOperations(null, edits, _ => null);
419
await this._updateDiffInfoSeq('accepted');
420
if (this._diffInfo.get().identical) {
421
this._didAcceptOrRejectAllHunks.fire(ModifiedFileEntryState.Accepted);
422
}
423
this._accessibilitySignalService.playSignal(AccessibilitySignal.editsKept, { allowManyInParallel: true });
424
return true;
425
}
426
427
private async _undoHunk(change: DetailedLineRangeMapping): Promise<boolean> {
428
if (!this._diffInfo.get().changes.includes(change)) {
429
return false;
430
}
431
const edits: ISingleEditOperation[] = [];
432
for (const edit of change.innerChanges ?? []) {
433
const newText = this.originalModel.getValueInRange(edit.originalRange);
434
edits.push(EditOperation.replace(edit.modifiedRange, newText));
435
}
436
this.modifiedModel.pushEditOperations(null, edits, _ => null);
437
await this._updateDiffInfoSeq('rejected');
438
if (this._diffInfo.get().identical) {
439
this._didAcceptOrRejectAllHunks.fire(ModifiedFileEntryState.Rejected);
440
}
441
this._accessibilitySignalService.playSignal(AccessibilitySignal.editsUndone, { allowManyInParallel: true });
442
return true;
443
}
444
445
public async getDiffInfo() {
446
if (!this._diffOperation) {
447
this._updateDiffInfoSeq();
448
}
449
450
await this._diffOperation;
451
return this._diffInfo.get();
452
}
453
454
455
private async _updateDiffInfoSeq(notifyAction: 'accepted' | 'rejected' | undefined = undefined) {
456
const myDiffOperationId = ++this._diffOperationIds;
457
await Promise.resolve(this._diffOperation);
458
const previousCount = this.lineChangeCount;
459
const previousAdded = this.linesAdded;
460
const previousRemoved = this.linesRemoved;
461
if (this._diffOperationIds === myDiffOperationId) {
462
const thisDiffOperation = this._updateDiffInfo();
463
this._diffOperation = thisDiffOperation;
464
await thisDiffOperation;
465
if (notifyAction) {
466
const affectedLines = {
467
linesAdded: previousAdded - this.linesAdded,
468
linesRemoved: previousRemoved - this.linesRemoved,
469
lineCount: previousCount - this.lineChangeCount,
470
hasRemainingEdits: this.lineChangeCount > 0
471
};
472
this.notifyHunkAction(notifyAction, affectedLines);
473
}
474
}
475
}
476
477
public hasHunkAt(range: IRange) {
478
// return true if the range overlaps a diff range
479
return this._diffInfo.get().changes.some(c => c.modified.intersectsStrict(LineRange.fromRangeInclusive(range)));
480
}
481
482
private async _updateDiffInfo(): Promise<IDocumentDiff | undefined> {
483
484
if (this.originalModel.isDisposed() || this.modifiedModel.isDisposed() || this._store.isDisposed) {
485
return undefined;
486
}
487
488
if (this.state.get() !== ModifiedFileEntryState.Modified) {
489
this._diffInfo.set(nullDocumentDiff, undefined);
490
this._originalToModifiedEdit = StringEdit.empty;
491
return nullDocumentDiff;
492
}
493
494
const docVersionNow = this.modifiedModel.getVersionId();
495
const snapshotVersionNow = this.originalModel.getVersionId();
496
497
const diff = await this._editorWorkerService.computeDiff(
498
this.originalModel.uri,
499
this.modifiedModel.uri,
500
{
501
ignoreTrimWhitespace: false, // NEVER ignore whitespace so that undo/accept edits are correct and so that all changes (1 of 2) are spelled out
502
computeMoves: false,
503
maxComputationTimeMs: 3000
504
},
505
'advanced'
506
);
507
508
if (this.originalModel.isDisposed() || this.modifiedModel.isDisposed() || this._store.isDisposed) {
509
return undefined;
510
}
511
512
// only update the diff if the documents didn't change in the meantime
513
if (this.modifiedModel.getVersionId() === docVersionNow && this.originalModel.getVersionId() === snapshotVersionNow) {
514
const diff2 = diff ?? nullDocumentDiff;
515
this._diffInfo.set(diff2, undefined);
516
this._originalToModifiedEdit = offsetEditFromLineRangeMapping(this.originalModel, this.modifiedModel, diff2.changes);
517
return diff2;
518
}
519
return undefined;
520
}
521
}
522
523