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