Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts
5257 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 { DeferredPromise, ITask, Sequencer, SequencerByKey, timeout } from '../../../../../base/common/async.js';
7
import { VSBuffer } from '../../../../../base/common/buffer.js';
8
import { CancellationToken } from '../../../../../base/common/cancellation.js';
9
import { BugIndicatingError } from '../../../../../base/common/errors.js';
10
import { Emitter } from '../../../../../base/common/event.js';
11
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
12
import { Iterable } from '../../../../../base/common/iterator.js';
13
import { Disposable, DisposableStore, dispose } from '../../../../../base/common/lifecycle.js';
14
import { ResourceMap } from '../../../../../base/common/map.js';
15
import { derived, IObservable, IReader, ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js';
16
import { isEqual } from '../../../../../base/common/resources.js';
17
import { hasKey, Mutable } from '../../../../../base/common/types.js';
18
import { URI } from '../../../../../base/common/uri.js';
19
import { IBulkEditService } from '../../../../../editor/browser/services/bulkEditService.js';
20
import { Range } from '../../../../../editor/common/core/range.js';
21
import { TextEdit } from '../../../../../editor/common/languages.js';
22
import { ILanguageService } from '../../../../../editor/common/languages/language.js';
23
import { ITextModel } from '../../../../../editor/common/model.js';
24
import { IModelService } from '../../../../../editor/common/services/model.js';
25
import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';
26
import { localize } from '../../../../../nls.js';
27
import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
28
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
29
import { EditorActivation } from '../../../../../platform/editor/common/editor.js';
30
import { IFileService } from '../../../../../platform/files/common/files.js';
31
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
32
import { ILogService } from '../../../../../platform/log/common/log.js';
33
import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js';
34
import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js';
35
import { IEditorService } from '../../../../services/editor/common/editorService.js';
36
import { MultiDiffEditor } from '../../../multiDiffEditor/browser/multiDiffEditor.js';
37
import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js';
38
import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js';
39
import { INotebookService } from '../../../notebook/common/notebookService.js';
40
import { chatEditingSessionIsReady, ChatEditingSessionState, ChatEditKind, getMultiDiffSourceUri, IChatEditingSession, IEditSessionEntryDiff, IModifiedEntryTelemetryInfo, IModifiedFileEntry, ISnapshotEntry, IStreamingEdits, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js';
41
import { IChatResponseModel } from '../../common/model/chatModel.js';
42
import { IChatProgress, IChatWorkspaceEdit } from '../../common/chatService/chatService.js';
43
import { ChatAgentLocation } from '../../common/constants.js';
44
import { IChatEditingCheckpointTimeline } from './chatEditingCheckpointTimeline.js';
45
import { ChatEditingCheckpointTimelineImpl, IChatEditingTimelineFsDelegate } from './chatEditingCheckpointTimelineImpl.js';
46
import { ChatEditingDeletedFileEntry } from './chatEditingDeletedFileEntry.js';
47
import { ChatEditingModifiedDocumentEntry } from './chatEditingModifiedDocumentEntry.js';
48
import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js';
49
import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js';
50
import { FileOperation, FileOperationType } from './chatEditingOperations.js';
51
import { IChatEditingExplanationModelManager, IExplanationDiffInfo, IExplanationGenerationHandle } from './chatEditingExplanationModelManager.js';
52
import { ChatEditingSessionStorage, IChatEditingSessionStop, StoredSessionState } from './chatEditingSessionStorage.js';
53
import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js';
54
import { getChatSessionType } from '../../common/model/chatUri.js';
55
import { AgentSessionProviders } from '../agentSessions/agentSessions.js';
56
57
const enum NotExistBehavior {
58
Create,
59
Abort,
60
}
61
62
class ThrottledSequencer extends Sequencer {
63
64
private _size = 0;
65
66
constructor(
67
private readonly _minDuration: number,
68
private readonly _maxOverallDelay: number
69
) {
70
super();
71
}
72
73
override queue<T>(promiseTask: ITask<Promise<T>>): Promise<T> {
74
75
this._size += 1;
76
77
const noDelay = this._size * this._minDuration > this._maxOverallDelay;
78
79
return super.queue(async () => {
80
try {
81
const p1 = promiseTask();
82
const p2 = noDelay
83
? Promise.resolve(undefined)
84
: timeout(this._minDuration, CancellationToken.None);
85
86
const [result] = await Promise.all([p1, p2]);
87
return result;
88
89
} finally {
90
this._size -= 1;
91
}
92
});
93
}
94
}
95
96
function createOpeningEditCodeBlock(uri: URI, isNotebook: boolean, undoStopId: string): IChatProgress[] {
97
return [
98
{
99
kind: 'markdownContent',
100
content: new MarkdownString('\n````\n')
101
},
102
{
103
kind: 'codeblockUri',
104
uri,
105
isEdit: true,
106
undoStopId
107
},
108
{
109
kind: 'markdownContent',
110
content: new MarkdownString('\n````\n')
111
},
112
isNotebook
113
? {
114
kind: 'notebookEdit',
115
uri,
116
edits: [],
117
done: false,
118
isExternalEdit: true
119
}
120
: {
121
kind: 'textEdit',
122
uri,
123
edits: [],
124
done: false,
125
isExternalEdit: true
126
},
127
];
128
}
129
130
131
export class ChatEditingSession extends Disposable implements IChatEditingSession {
132
private readonly _state = observableValue<ChatEditingSessionState>(this, ChatEditingSessionState.Initial);
133
private readonly _timeline: IChatEditingCheckpointTimeline;
134
135
/**
136
* Contains the contents of a file when the AI first began doing edits to it.
137
*/
138
private readonly _initialFileContents = new ResourceMap<string>();
139
140
private readonly _baselineCreationLocks = new SequencerByKey</* URI.path */ string>();
141
private readonly _streamingEditLocks = new SequencerByKey</* URI */ string>();
142
143
/**
144
* Tracks active external edit operations.
145
* Key is operationId, value contains the operation state.
146
*/
147
private readonly _externalEditOperations = new Map<number, {
148
responseModel: IChatResponseModel;
149
snapshots: ResourceMap<string | undefined>;
150
undoStopId: string;
151
releaseLocks: () => void;
152
}>();
153
154
private readonly _entriesObs = observableValue<readonly AbstractChatEditingModifiedFileEntry[]>(this, []);
155
public readonly entries: IObservable<readonly IModifiedFileEntry[]> = derived(reader => {
156
const state = this._state.read(reader);
157
if (state === ChatEditingSessionState.Disposed || state === ChatEditingSessionState.Initial) {
158
return [];
159
} else {
160
return this._entriesObs.read(reader);
161
}
162
});
163
164
private _editorPane: MultiDiffEditor | undefined;
165
private _explanationHandle: IExplanationGenerationHandle | undefined;
166
167
get state(): IObservable<ChatEditingSessionState> {
168
return this._state;
169
}
170
171
public readonly canUndo: IObservable<boolean>;
172
public readonly canRedo: IObservable<boolean>;
173
174
public get requestDisablement() {
175
return this._timeline.requestDisablement;
176
}
177
178
private readonly _onDidDispose = new Emitter<void>();
179
get onDidDispose() {
180
this._assertNotDisposed();
181
return this._onDidDispose.event;
182
}
183
184
constructor(
185
readonly chatSessionResource: URI,
186
readonly isGlobalEditingSession: boolean,
187
private _lookupExternalEntry: (uri: URI) => AbstractChatEditingModifiedFileEntry | undefined,
188
transferFrom: IChatEditingSession | undefined,
189
@IInstantiationService private readonly _instantiationService: IInstantiationService,
190
@IModelService private readonly _modelService: IModelService,
191
@ILanguageService private readonly _languageService: ILanguageService,
192
@ITextModelService private readonly _textModelService: ITextModelService,
193
@IBulkEditService public readonly _bulkEditService: IBulkEditService,
194
@IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService,
195
@IEditorService private readonly _editorService: IEditorService,
196
@INotebookService private readonly _notebookService: INotebookService,
197
@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService,
198
@ILogService private readonly _logService: ILogService,
199
@IConfigurationService private readonly configurationService: IConfigurationService,
200
@IFileService private readonly _fileService: IFileService,
201
@IChatEditingExplanationModelManager private readonly _explanationModelManager: IChatEditingExplanationModelManager,
202
) {
203
super();
204
this._timeline = this._instantiationService.createInstance(
205
ChatEditingCheckpointTimelineImpl,
206
chatSessionResource,
207
this._getTimelineDelegate(),
208
);
209
210
this.canRedo = this._timeline.canRedo.map((hasHistory, reader) =>
211
hasHistory && this._state.read(reader) === ChatEditingSessionState.Idle);
212
this.canUndo = this._timeline.canUndo.map((hasHistory, reader) =>
213
hasHistory && this._state.read(reader) === ChatEditingSessionState.Idle);
214
215
this._init(transferFrom);
216
}
217
218
private _getTimelineDelegate(): IChatEditingTimelineFsDelegate {
219
return {
220
createFile: (uri, content) => {
221
return this._bulkEditService.apply({
222
edits: [{
223
newResource: uri,
224
options: {
225
overwrite: true,
226
contents: content ? Promise.resolve(VSBuffer.fromString(content)) : undefined,
227
},
228
}],
229
});
230
},
231
deleteFile: async (uri) => {
232
const entries = this._entriesObs.get().filter(e => !isEqual(e.modifiedURI, uri));
233
this._entriesObs.set(entries, undefined);
234
await this._bulkEditService.apply({ edits: [{ oldResource: uri, options: { ignoreIfNotExists: true } }] });
235
},
236
renameFile: async (fromUri, toUri) => {
237
const entries = this._entriesObs.get();
238
const previousEntry = entries.find(e => isEqual(e.modifiedURI, fromUri));
239
if (previousEntry) {
240
const newEntry = await this._getOrCreateModifiedFileEntry(toUri, NotExistBehavior.Create, previousEntry.telemetryInfo, this._getCurrentTextOrNotebookSnapshot(previousEntry));
241
previousEntry.dispose();
242
this._entriesObs.set(entries.map(e => e === previousEntry ? newEntry : e), undefined);
243
}
244
},
245
setContents: async (uri, content, telemetryInfo) => {
246
const entry = await this._getOrCreateModifiedFileEntry(uri, NotExistBehavior.Create, telemetryInfo);
247
248
// We apply these edits as 'agent edits' which will by default make them get keep
249
// /undo indicators. This is good in the case the edits were never initially accepted,
250
// but if the file was already in an accepted state we should not make it modified again.
251
const state = entry.state.get();
252
if (entry instanceof ChatEditingModifiedNotebookEntry) {
253
await entry.restoreModifiedModelFromSnapshot(content);
254
} else {
255
await entry.acceptAgentEdits(uri, [{ range: new Range(1, 1, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER), text: content }], true, undefined);
256
}
257
258
if (state !== ModifiedFileEntryState.Modified) {
259
await entry.accept();
260
}
261
}
262
};
263
}
264
265
private async _init(transferFrom?: IChatEditingSession): Promise<void> {
266
const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource);
267
let restoredSessionState: StoredSessionState | undefined;
268
if (transferFrom instanceof ChatEditingSession) {
269
restoredSessionState = transferFrom._getStoredState(this.chatSessionResource);
270
} else {
271
restoredSessionState = await storage.restoreState().catch(err => {
272
this._logService.error(`Error restoring chat editing session state for ${this.chatSessionResource}`, err);
273
return undefined;
274
});
275
276
if (this._store.isDisposed) {
277
return; // disposed while restoring
278
}
279
}
280
281
282
if (restoredSessionState) {
283
for (const [uri, content] of restoredSessionState.initialFileContents) {
284
this._initialFileContents.set(uri, content);
285
}
286
if (restoredSessionState.timeline) {
287
transaction(tx => this._timeline.restoreFromState(restoredSessionState.timeline!, tx));
288
}
289
await this._initEntries(restoredSessionState.recentSnapshot);
290
}
291
292
this._state.set(ChatEditingSessionState.Idle, undefined);
293
}
294
295
private _getEntry(uri: URI): AbstractChatEditingModifiedFileEntry | undefined {
296
uri = CellUri.parse(uri)?.notebook ?? uri;
297
return this._entriesObs.get().find(e => isEqual(e.modifiedURI, uri));
298
}
299
300
public getEntry(uri: URI): IModifiedFileEntry | undefined {
301
return this._getEntry(uri);
302
}
303
304
public readEntry(uri: URI, reader: IReader | undefined): IModifiedFileEntry | undefined {
305
uri = CellUri.parse(uri)?.notebook ?? uri;
306
return this._entriesObs.read(reader).find(e => isEqual(e.modifiedURI, uri));
307
}
308
309
public storeState(): Promise<void> {
310
const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource);
311
return storage.storeState(this._getStoredState());
312
}
313
314
private _getStoredState(sessionResource = this.chatSessionResource): StoredSessionState {
315
const entries = new ResourceMap<ISnapshotEntry>();
316
for (const entry of this._entriesObs.get()) {
317
entries.set(entry.modifiedURI, entry.createSnapshot(sessionResource, undefined, undefined));
318
}
319
320
const state: StoredSessionState = {
321
initialFileContents: this._initialFileContents,
322
timeline: this._timeline.getStateForPersistence(),
323
recentSnapshot: { entries, stopId: undefined },
324
};
325
326
return state;
327
}
328
329
public getEntryDiffBetweenStops(uri: URI, requestId: string | undefined, stopId: string | undefined) {
330
return this._timeline.getEntryDiffBetweenStops(uri, requestId, stopId);
331
}
332
333
public getEntryDiffBetweenRequests(uri: URI, startRequestId: string, stopRequestId: string) {
334
return this._timeline.getEntryDiffBetweenRequests(uri, startRequestId, stopRequestId);
335
}
336
337
public getDiffsForFilesInSession() {
338
return this._timeline.getDiffsForFilesInSession();
339
}
340
341
public getDiffForSession() {
342
return this._timeline.getDiffForSession();
343
}
344
345
public getDiffsForFilesInRequest(requestId: string): IObservable<readonly IEditSessionEntryDiff[]> {
346
return this._timeline.getDiffsForFilesInRequest(requestId);
347
}
348
349
public hasEditsInRequest(requestId: string, reader?: IReader): boolean {
350
return this._timeline.hasEditsInRequest(requestId, reader);
351
}
352
353
public createSnapshot(requestId: string, undoStop: string | undefined): void {
354
const label = undoStop ? `Request ${requestId} - Stop ${undoStop}` : `Request ${requestId}`;
355
this._timeline.createCheckpoint(requestId, undoStop, label);
356
}
357
358
public async getSnapshotContents(requestId: string, uri: URI, stopId: string | undefined): Promise<VSBuffer | undefined> {
359
const content = await this._timeline.getContentAtStop(requestId, uri, stopId);
360
return typeof content === 'string' ? VSBuffer.fromString(content) : content;
361
}
362
363
public async getSnapshotModel(requestId: string, undoStop: string | undefined, snapshotUri: URI): Promise<ITextModel | null> {
364
await this._baselineCreationLocks.peek(snapshotUri.path);
365
366
const content = await this._timeline.getContentAtStop(requestId, snapshotUri, undoStop);
367
if (content === undefined) {
368
return null;
369
}
370
371
const contentStr = typeof content === 'string' ? content : content.toString();
372
const model = this._modelService.createModel(contentStr, this._languageService.createByFilepathOrFirstLine(snapshotUri), snapshotUri, false);
373
374
const store = new DisposableStore();
375
store.add(model.onWillDispose(() => store.dispose()));
376
store.add(this._timeline.onDidChangeContentsAtStop(requestId, snapshotUri, undoStop, c => model.setValue(c)));
377
378
return model;
379
}
380
381
public getSnapshotUri(requestId: string, uri: URI, stopId: string | undefined): URI | undefined {
382
return this._timeline.getContentURIAtStop(requestId, uri, stopId);
383
}
384
385
public async restoreSnapshot(requestId: string, stopId: string | undefined): Promise<void> {
386
const checkpointId = this._timeline.getCheckpointIdForRequest(requestId, stopId);
387
if (checkpointId) {
388
await this._timeline.navigateToCheckpoint(checkpointId);
389
}
390
}
391
392
private _assertNotDisposed(): void {
393
if (this._state.get() === ChatEditingSessionState.Disposed) {
394
throw new BugIndicatingError(`Cannot access a disposed editing session`);
395
}
396
}
397
398
async accept(...uris: URI[]): Promise<void> {
399
if (await this._operateEntry('accept', uris)) {
400
this._accessibilitySignalService.playSignal(AccessibilitySignal.editsKept, { allowManyInParallel: true });
401
}
402
403
}
404
405
async reject(...uris: URI[]): Promise<void> {
406
if (await this._operateEntry('reject', uris)) {
407
this._accessibilitySignalService.playSignal(AccessibilitySignal.editsUndone, { allowManyInParallel: true });
408
}
409
}
410
411
private async _operateEntry(action: 'accept' | 'reject', uris: URI[]): Promise<number> {
412
this._assertNotDisposed();
413
414
const applicableEntries = this._entriesObs.get()
415
.filter(e => uris.length === 0 || uris.some(u => isEqual(u, e.modifiedURI)))
416
.filter(e => !e.isCurrentlyBeingModifiedBy.get())
417
.filter(e => e.state.get() === ModifiedFileEntryState.Modified);
418
419
if (applicableEntries.length === 0) {
420
return 0;
421
}
422
423
// Perform all I/O operations in parallel, each resolving to a state transition callback
424
const method = action === 'accept' ? 'acceptDeferred' : 'rejectDeferred';
425
const transitionCallbacks = await Promise.all(
426
applicableEntries.map(entry => entry[method]().catch(err => {
427
this._logService.error(`Error calling ${method} on entry ${entry.modifiedURI}`, err);
428
}))
429
);
430
431
// Execute all state transitions atomically in a single transaction
432
transaction(tx => {
433
transitionCallbacks.forEach(callback => callback?.(tx));
434
});
435
436
return applicableEntries.length;
437
}
438
439
async show(previousChanges?: boolean): Promise<void> {
440
this._assertNotDisposed();
441
if (this._editorPane) {
442
if (this._editorPane.isVisible()) {
443
return;
444
} else if (this._editorPane.input) {
445
await this._editorGroupsService.activeGroup.openEditor(this._editorPane.input, { pinned: true, activation: EditorActivation.ACTIVATE });
446
return;
447
}
448
}
449
const input = MultiDiffEditorInput.fromResourceMultiDiffEditorInput({
450
multiDiffSource: getMultiDiffSourceUri(this, previousChanges),
451
label: localize('multiDiffEditorInput.name', "Suggested Edits")
452
}, this._instantiationService);
453
454
this._editorPane = await this._editorGroupsService.activeGroup.openEditor(input, { pinned: true, activation: EditorActivation.ACTIVATE }) as MultiDiffEditor | undefined;
455
}
456
457
private _stopPromise: Promise<void> | undefined;
458
459
async stop(clearState = false): Promise<void> {
460
this._stopPromise ??= Promise.allSettled([this._performStop(), this.storeState()]).then(() => { });
461
await this._stopPromise;
462
if (clearState) {
463
await this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource).clearState();
464
}
465
}
466
467
private async _performStop(): Promise<void> {
468
// Close out all open files
469
const schemes = [AbstractChatEditingModifiedFileEntry.scheme, ChatEditingTextModelContentProvider.scheme];
470
await Promise.allSettled(this._editorGroupsService.groups.flatMap(async (g) => {
471
return g.editors.map(async (e) => {
472
if ((e instanceof MultiDiffEditorInput && e.initialResources?.some(r => r.originalUri && schemes.indexOf(r.originalUri.scheme) !== -1))
473
|| (e instanceof DiffEditorInput && e.original.resource && schemes.indexOf(e.original.resource.scheme) !== -1)) {
474
await g.closeEditor(e);
475
}
476
});
477
}));
478
}
479
480
override dispose() {
481
this._assertNotDisposed();
482
this.clearExplanations();
483
dispose(this._entriesObs.get());
484
super.dispose();
485
this._state.set(ChatEditingSessionState.Disposed, undefined);
486
this._onDidDispose.fire();
487
this._onDidDispose.dispose();
488
}
489
490
private get isDisposed() {
491
return this._state.get() === ChatEditingSessionState.Disposed;
492
}
493
494
startStreamingEdits(resource: URI, responseModel: IChatResponseModel, inUndoStop: string | undefined): IStreamingEdits {
495
const completePromise = new DeferredPromise<void>();
496
const startPromise = new DeferredPromise<void>();
497
498
// Sequence all edits made this this resource in this streaming edits instance,
499
// and also sequence the resource overall in the rare (currently invalid?) case
500
// that edits are made in parallel to the same resource,
501
const sequencer = new ThrottledSequencer(15, 1000);
502
sequencer.queue(() => startPromise.p);
503
504
// Lock around creating the baseline so we don't fail to resolve models
505
// in the edit pills if they render quickly
506
this._baselineCreationLocks.queue(resource.path, () => startPromise.p);
507
508
this._streamingEditLocks.queue(resource.toString(), async () => {
509
await chatEditingSessionIsReady(this);
510
511
if (!this.isDisposed) {
512
await this._acceptStreamingEditsStart(responseModel, inUndoStop, resource);
513
}
514
515
startPromise.complete();
516
return completePromise.p;
517
});
518
519
520
let didComplete = false;
521
522
return {
523
pushText: (edits, isLastEdits) => {
524
sequencer.queue(async () => {
525
if (!this.isDisposed) {
526
await this._acceptEdits(resource, edits, isLastEdits, responseModel);
527
}
528
});
529
},
530
pushNotebookCellText: (cell, edits, isLastEdits) => {
531
sequencer.queue(async () => {
532
if (!this.isDisposed) {
533
await this._acceptEdits(cell, edits, isLastEdits, responseModel);
534
}
535
});
536
},
537
pushNotebook: (edits, isLastEdits) => {
538
sequencer.queue(async () => {
539
if (!this.isDisposed) {
540
await this._acceptEdits(resource, edits, isLastEdits, responseModel);
541
}
542
});
543
},
544
complete: () => {
545
if (didComplete) {
546
return;
547
}
548
549
didComplete = true;
550
sequencer.queue(async () => {
551
if (!this.isDisposed) {
552
await this._acceptEdits(resource, [], true, responseModel);
553
await this._resolve(responseModel.requestId, inUndoStop, resource);
554
completePromise.complete();
555
}
556
});
557
},
558
};
559
}
560
561
startDeletion(resource: URI, responseModel: IChatResponseModel, undoStopId: string): void {
562
this._assertNotDisposed();
563
564
// Queue the deletion operation with proper locking
565
this._streamingEditLocks.queue(resource.toString(), async () => {
566
if (this.isDisposed) {
567
return;
568
}
569
570
await chatEditingSessionIsReady(this);
571
572
// Check if file exists
573
let fileContent: string;
574
try {
575
const content = await this._fileService.readFile(resource);
576
fileContent = content.value.toString();
577
} catch (e) {
578
// File doesn't exist, nothing to delete
579
this._logService.warn(`Cannot delete file ${resource.toString()}: file does not exist`);
580
return;
581
}
582
583
// Check if there's already an entry for this file
584
const existingEntry = this._getEntry(resource);
585
if (existingEntry) {
586
// If there's already an entry, we need to handle it differently
587
// For now, we'll just collapse it and proceed with deletion
588
existingEntry.dispose();
589
const entries = this._entriesObs.get().filter(e => e !== existingEntry);
590
this._entriesObs.set(entries, undefined);
591
}
592
593
// Store initial content for timeline restoration
594
if (!this._initialFileContents.has(resource)) {
595
this._initialFileContents.set(resource, fileContent);
596
}
597
598
// Delete the file on disk
599
await this._bulkEditService.apply({
600
edits: [{ oldResource: resource, options: { ignoreIfNotExists: true } }]
601
});
602
603
// Record the delete operation in the timeline
604
this._timeline.recordFileOperation({
605
type: FileOperationType.Delete,
606
uri: resource,
607
requestId: responseModel.requestId,
608
epoch: this._timeline.incrementEpoch(),
609
finalContent: fileContent
610
});
611
612
// Create a deleted file entry
613
const telemetryInfo = this._getTelemetryInfoForModel(responseModel);
614
const languageSelection = this._languageService.createByFilepathOrFirstLine(resource);
615
const entry = this._instantiationService.createInstance(
616
ChatEditingDeletedFileEntry,
617
resource,
618
fileContent,
619
{ collapse: (tx: ITransaction | undefined) => this._collapse(resource, tx) },
620
telemetryInfo,
621
languageSelection.languageId
622
);
623
624
// Add entry to the entries observable
625
const entries = [...this._entriesObs.get(), entry];
626
this._entriesObs.set(entries, undefined);
627
});
628
}
629
630
applyWorkspaceEdit(edit: IChatWorkspaceEdit, responseModel: IChatResponseModel, undoStopId: string): void {
631
for (const fileEdit of edit.edits) {
632
if (fileEdit.oldResource && !fileEdit.newResource) {
633
// File deletion
634
this.startDeletion(fileEdit.oldResource, responseModel, undoStopId);
635
}
636
// Future: handle file creations and renames
637
}
638
}
639
640
async startExternalEdits(responseModel: IChatResponseModel, operationId: number, resources: URI[], undoStopId: string): Promise<IChatProgress[]> {
641
const snapshots = new ResourceMap<string | undefined>();
642
const acquiredLockPromises: DeferredPromise<void>[] = [];
643
const releaseLockPromises: DeferredPromise<void>[] = [];
644
const progress: IChatProgress[] = [];
645
const telemetryInfo = this._getTelemetryInfoForModel(responseModel);
646
647
await chatEditingSessionIsReady(this);
648
649
// Acquire locks for each resource and take snapshots
650
for (const resource of resources) {
651
const releaseLock = new DeferredPromise<void>();
652
releaseLockPromises.push(releaseLock);
653
654
const acquiredLock = new DeferredPromise<void>();
655
acquiredLockPromises.push(acquiredLock);
656
657
this._streamingEditLocks.queue(resource.toString(), async () => {
658
if (this.isDisposed) {
659
acquiredLock.complete();
660
return;
661
}
662
663
const entry = await this._getOrCreateModifiedFileEntry(resource, NotExistBehavior.Abort, telemetryInfo);
664
if (entry) {
665
await this._acceptStreamingEditsStart(responseModel, undoStopId, resource);
666
}
667
668
669
const notebookUri = CellUri.parse(resource)?.notebook || resource;
670
progress.push(...createOpeningEditCodeBlock(resource, this._notebookService.hasSupportedNotebooks(notebookUri), undoStopId));
671
672
// Save to disk to ensure disk state is current before external edits
673
await entry?.save();
674
675
// Take snapshot of current state
676
snapshots.set(resource, entry && this._getCurrentTextOrNotebookSnapshot(entry));
677
entry?.startExternalEdit();
678
acquiredLock.complete();
679
680
// Wait for the lock to be released by stopExternalEdits
681
return releaseLock.p;
682
});
683
}
684
685
await Promise.all(acquiredLockPromises.map(p => p.p));
686
this.createSnapshot(responseModel.requestId, undoStopId);
687
688
// Store the operation state
689
this._externalEditOperations.set(operationId, {
690
responseModel,
691
snapshots,
692
undoStopId,
693
releaseLocks: () => releaseLockPromises.forEach(p => p.complete())
694
});
695
696
return progress;
697
}
698
699
async stopExternalEdits(responseModel: IChatResponseModel, operationId: number): Promise<IChatProgress[]> {
700
const operation = this._externalEditOperations.get(operationId);
701
if (!operation) {
702
this._logService.warn(`stopExternalEdits called for unknown operation ${operationId}`);
703
return [];
704
}
705
706
this._externalEditOperations.delete(operationId);
707
708
const progress: IChatProgress[] = [];
709
710
try {
711
// For each resource, compute the diff and create edit parts
712
for (const [resource, beforeSnapshot] of operation.snapshots) {
713
let entry = this._getEntry(resource);
714
715
// Files that did not exist on disk before may not exist in our working
716
// set yet. Create those if that's the case.
717
if (!entry && beforeSnapshot === undefined) {
718
entry = await this._getOrCreateModifiedFileEntry(resource, NotExistBehavior.Abort, this._getTelemetryInfoForModel(responseModel), '');
719
if (entry) {
720
entry.startExternalEdit();
721
entry.acceptStreamingEditsStart(responseModel, operation.undoStopId, undefined);
722
}
723
}
724
725
if (!entry) {
726
continue;
727
}
728
729
// Reload from disk to ensure in-memory model is in sync with file system
730
await entry.revertToDisk();
731
732
// Take new snapshot after external changes
733
const afterSnapshot = this._getCurrentTextOrNotebookSnapshot(entry);
734
735
// Compute edits from the snapshots
736
let edits: (TextEdit | ICellEditOperation)[] = [];
737
if (beforeSnapshot === undefined) {
738
this._timeline.recordFileOperation({
739
type: FileOperationType.Create,
740
uri: resource,
741
requestId: responseModel.requestId,
742
epoch: this._timeline.incrementEpoch(),
743
initialContent: afterSnapshot,
744
telemetryInfo: entry.telemetryInfo,
745
});
746
} else {
747
edits = await entry.computeEditsFromSnapshots(beforeSnapshot, afterSnapshot);
748
this._recordEditOperations(entry, resource, edits, responseModel);
749
}
750
751
progress.push(entry instanceof ChatEditingModifiedNotebookEntry ? {
752
kind: 'notebookEdit',
753
uri: resource,
754
edits: edits as ICellEditOperation[],
755
done: true,
756
isExternalEdit: true
757
} : {
758
kind: 'textEdit',
759
uri: resource,
760
edits: edits as TextEdit[],
761
done: true,
762
isExternalEdit: true
763
});
764
765
// Mark as no longer being modified
766
await entry.acceptStreamingEditsEnd();
767
768
// Accept the changes for background sessions
769
if (getChatSessionType(this.chatSessionResource) === AgentSessionProviders.Background) {
770
await entry.accept();
771
}
772
773
// Clear external edit mode
774
entry.stopExternalEdit();
775
}
776
} finally {
777
// Release all the locks
778
operation.releaseLocks();
779
780
const hasOtherTasks = Iterable.some(this._streamingEditLocks.keys(), k => !operation.snapshots.has(URI.parse(k)));
781
if (!hasOtherTasks) {
782
this._state.set(ChatEditingSessionState.Idle, undefined);
783
}
784
}
785
786
787
return progress;
788
}
789
790
async undoInteraction(): Promise<void> {
791
await this._timeline.undoToLastCheckpoint();
792
}
793
794
async redoInteraction(): Promise<void> {
795
await this._timeline.redoToNextCheckpoint();
796
}
797
798
async triggerExplanationGeneration(): Promise<void> {
799
// Clear any existing explanations first
800
this.clearExplanations();
801
802
const entries = this._entriesObs.get();
803
const diffInfos: IExplanationDiffInfo[] = [];
804
for (const entry of entries) {
805
if (entry instanceof ChatEditingModifiedDocumentEntry) {
806
const diff = await entry.getDiffInfo();
807
diffInfos.push({
808
changes: diff.changes,
809
identical: diff.identical,
810
originalModel: entry.originalModel,
811
modifiedModel: entry.modifiedModel,
812
});
813
}
814
}
815
816
if (diffInfos.length > 0) {
817
this._explanationHandle = this._explanationModelManager.generateExplanations(diffInfos, this.chatSessionResource, CancellationToken.None);
818
await this._explanationHandle.completed;
819
}
820
}
821
822
clearExplanations(): void {
823
if (this._explanationHandle) {
824
this._explanationHandle.dispose();
825
this._explanationHandle = undefined;
826
}
827
}
828
829
hasExplanations(): boolean {
830
return this._explanationHandle !== undefined;
831
}
832
833
private _recordEditOperations(entry: AbstractChatEditingModifiedFileEntry, resource: URI, edits: (TextEdit | ICellEditOperation)[], responseModel: IChatResponseModel): void {
834
// Determine if these are text edits or notebook edits
835
const isNotebookEdits = edits.length > 0 && hasKey(edits[0], { cells: true });
836
837
if (isNotebookEdits) {
838
// Record notebook edit operation
839
const notebookEdits = edits as ICellEditOperation[];
840
this._timeline.recordFileOperation({
841
type: FileOperationType.NotebookEdit,
842
uri: resource,
843
requestId: responseModel.requestId,
844
epoch: this._timeline.incrementEpoch(),
845
cellEdits: notebookEdits
846
});
847
} else {
848
let cellIndex: number | undefined;
849
if (entry instanceof ChatEditingModifiedNotebookEntry) {
850
const cellUri = CellUri.parse(resource);
851
if (cellUri) {
852
const i = entry.getIndexOfCellHandle(cellUri.handle);
853
if (i !== -1) {
854
cellIndex = i;
855
}
856
}
857
}
858
859
const textEdits = edits as TextEdit[];
860
this._timeline.recordFileOperation({
861
type: FileOperationType.TextEdit,
862
uri: resource,
863
requestId: responseModel.requestId,
864
epoch: this._timeline.incrementEpoch(),
865
edits: textEdits,
866
cellIndex,
867
});
868
}
869
}
870
871
private _getCurrentTextOrNotebookSnapshot(entry: AbstractChatEditingModifiedFileEntry): string {
872
if (entry instanceof ChatEditingModifiedNotebookEntry) {
873
return entry.getCurrentSnapshot();
874
} else if (entry instanceof ChatEditingModifiedDocumentEntry) {
875
return entry.getCurrentContents();
876
} else if (entry instanceof ChatEditingDeletedFileEntry) {
877
return '';
878
} else {
879
throw new Error(`unknown entry type for ${entry.modifiedURI}`);
880
}
881
}
882
883
private async _acceptStreamingEditsStart(responseModel: IChatResponseModel, undoStop: string | undefined, resource: URI) {
884
const entry = await this._getOrCreateModifiedFileEntry(resource, NotExistBehavior.Create, this._getTelemetryInfoForModel(responseModel));
885
886
// Record file baseline if this is the first edit for this file in this request
887
if (!this._timeline.hasFileBaseline(resource, responseModel.requestId)) {
888
this._timeline.recordFileBaseline({
889
uri: resource,
890
requestId: responseModel.requestId,
891
content: this._getCurrentTextOrNotebookSnapshot(entry),
892
epoch: this._timeline.incrementEpoch(),
893
telemetryInfo: entry.telemetryInfo,
894
notebookViewType: entry instanceof ChatEditingModifiedNotebookEntry ? entry.viewType : undefined,
895
});
896
}
897
898
transaction((tx) => {
899
this._state.set(ChatEditingSessionState.StreamingEdits, tx);
900
entry.acceptStreamingEditsStart(responseModel, undoStop, tx);
901
// Note: Individual edit operations will be recorded by the file entries
902
});
903
904
return entry;
905
}
906
907
private async _initEntries({ entries }: IChatEditingSessionStop): Promise<void> {
908
// Reset all the files which are modified in this session state
909
// but which are not found in the snapshot
910
for (const entry of this._entriesObs.get()) {
911
const snapshotEntry = entries.get(entry.modifiedURI);
912
if (!snapshotEntry) {
913
await entry.resetToInitialContent();
914
entry.dispose();
915
}
916
}
917
918
const entriesArr: AbstractChatEditingModifiedFileEntry[] = [];
919
// Restore all entries from the snapshot
920
for (const snapshotEntry of entries.values()) {
921
let entry: AbstractChatEditingModifiedFileEntry | undefined;
922
923
if (snapshotEntry.isDeleted) {
924
// Create a deleted file entry
925
entry = this._instantiationService.createInstance(
926
ChatEditingDeletedFileEntry,
927
snapshotEntry.resource,
928
snapshotEntry.original, // original content before deletion
929
{ collapse: (tx: ITransaction | undefined) => this._collapse(snapshotEntry.resource, tx) },
930
snapshotEntry.telemetryInfo,
931
snapshotEntry.languageId
932
);
933
await entry.restoreFromSnapshot(snapshotEntry, false);
934
} else {
935
entry = await this._getOrCreateModifiedFileEntry(snapshotEntry.resource, NotExistBehavior.Abort, snapshotEntry.telemetryInfo);
936
if (entry) {
937
const restoreToDisk = snapshotEntry.state === ModifiedFileEntryState.Modified;
938
await entry.restoreFromSnapshot(snapshotEntry, restoreToDisk);
939
}
940
}
941
942
if (entry) {
943
entriesArr.push(entry);
944
}
945
}
946
947
this._entriesObs.set(entriesArr, undefined);
948
}
949
950
private async _acceptEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise<void> {
951
const entry = await this._getOrCreateModifiedFileEntry(resource, NotExistBehavior.Create, this._getTelemetryInfoForModel(responseModel));
952
953
// Record edit operations in the timeline if there are actual edits
954
if (textEdits.length > 0) {
955
this._recordEditOperations(entry, resource, textEdits, responseModel);
956
}
957
958
await entry.acceptAgentEdits(resource, textEdits, isLastEdits, responseModel);
959
}
960
961
private _getTelemetryInfoForModel(responseModel: IChatResponseModel): IModifiedEntryTelemetryInfo {
962
// Make these getters because the response result is not available when the file first starts to be edited
963
return new class implements IModifiedEntryTelemetryInfo {
964
get agentId() { return responseModel.agent?.id; }
965
get modelId() { return responseModel.request?.modelId; }
966
get modeId() { return responseModel.request?.modeInfo?.modeId; }
967
get command() { return responseModel.slashCommand?.name; }
968
get sessionResource() { return responseModel.session.sessionResource; }
969
get requestId() { return responseModel.requestId; }
970
get result() { return responseModel.result; }
971
get applyCodeBlockSuggestionId() { return responseModel.request?.modeInfo?.applyCodeBlockSuggestionId; }
972
973
get feature(): 'sideBarChat' | 'inlineChat' | undefined {
974
if (responseModel.session.initialLocation === ChatAgentLocation.Chat) {
975
return 'sideBarChat';
976
} else if (responseModel.session.initialLocation === ChatAgentLocation.EditorInline) {
977
return 'inlineChat';
978
}
979
return undefined;
980
}
981
};
982
}
983
984
private async _resolve(requestId: string, undoStop: string | undefined, resource: URI): Promise<void> {
985
const hasOtherTasks = Iterable.some(this._streamingEditLocks.keys(), k => k !== resource.toString());
986
if (!hasOtherTasks) {
987
this._state.set(ChatEditingSessionState.Idle, undefined);
988
}
989
990
const entry = this._getEntry(resource);
991
if (!entry) {
992
return;
993
}
994
995
// Create checkpoint for this edit completion
996
const label = undoStop ? `Request ${requestId} - Stop ${undoStop}` : `Request ${requestId}`;
997
this._timeline.createCheckpoint(requestId, undoStop, label);
998
999
return entry.acceptStreamingEditsEnd();
1000
}
1001
1002
/**
1003
* Retrieves or creates a modified file entry.
1004
*
1005
* @returns The modified file entry.
1006
*/
1007
private async _getOrCreateModifiedFileEntry(resource: URI, ifNotExists: NotExistBehavior.Create, telemetryInfo: IModifiedEntryTelemetryInfo, initialContent?: string): Promise<AbstractChatEditingModifiedFileEntry>;
1008
private async _getOrCreateModifiedFileEntry(resource: URI, ifNotExists: NotExistBehavior, telemetryInfo: IModifiedEntryTelemetryInfo, initialContent?: string): Promise<AbstractChatEditingModifiedFileEntry | undefined>;
1009
private async _getOrCreateModifiedFileEntry(resource: URI, ifNotExists: NotExistBehavior, telemetryInfo: IModifiedEntryTelemetryInfo, _initialContent?: string): Promise<AbstractChatEditingModifiedFileEntry | undefined> {
1010
1011
resource = CellUri.parse(resource)?.notebook ?? resource;
1012
1013
const existingEntry = this._entriesObs.get().find(e => isEqual(e.modifiedURI, resource));
1014
if (existingEntry) {
1015
// If the existing entry is a deleted file entry, we need to replace it with a new modified entry
1016
// This handles the case where a file was deleted and then recreated
1017
if (existingEntry instanceof ChatEditingDeletedFileEntry) {
1018
// Use the original content from the deleted entry as the initial content for the new entry
1019
const initialContentFromDeleted = existingEntry.state.get() === ModifiedFileEntryState.Modified
1020
? existingEntry.initialContent
1021
: undefined;
1022
1023
// Remove the deleted entry
1024
existingEntry.dispose();
1025
const entries = this._entriesObs.get().filter(e => e !== existingEntry);
1026
this._entriesObs.set(entries, undefined);
1027
1028
// Set the initial content from the deleted entry if it was still in modified state
1029
if (initialContentFromDeleted !== undefined) {
1030
_initialContent = initialContentFromDeleted;
1031
}
1032
// Fall through to create a new entry
1033
} else {
1034
if (telemetryInfo.requestId !== existingEntry.telemetryInfo.requestId) {
1035
existingEntry.updateTelemetryInfo(telemetryInfo);
1036
}
1037
return existingEntry;
1038
}
1039
}
1040
1041
let entry: AbstractChatEditingModifiedFileEntry;
1042
const existingExternalEntry = this._lookupExternalEntry(resource);
1043
if (existingExternalEntry) {
1044
entry = existingExternalEntry;
1045
1046
if (telemetryInfo.requestId !== entry.telemetryInfo.requestId) {
1047
entry.updateTelemetryInfo(telemetryInfo);
1048
}
1049
} else {
1050
const initialContent = _initialContent ?? this._initialFileContents.get(resource);
1051
// This gets manually disposed in .dispose() or in .restoreSnapshot()
1052
const maybeEntry = await this._createModifiedFileEntry(resource, telemetryInfo, ifNotExists, initialContent);
1053
if (!maybeEntry) {
1054
return undefined;
1055
}
1056
entry = maybeEntry;
1057
if (initialContent === undefined) {
1058
this._initialFileContents.set(resource, entry.initialContent);
1059
}
1060
}
1061
1062
// If an entry is deleted e.g. reverting a created file,
1063
// remove it from the entries and don't show it in the working set anymore
1064
// so that it can be recreated e.g. through retry
1065
const listener = entry.onDidDelete(() => {
1066
const newEntries = this._entriesObs.get().filter(e => !isEqual(e.modifiedURI, entry.modifiedURI));
1067
this._entriesObs.set(newEntries, undefined);
1068
this._editorService.closeEditors(this._editorService.findEditors(entry.modifiedURI));
1069
1070
if (!existingExternalEntry) {
1071
// don't dispose entries that are not yours!
1072
entry.dispose();
1073
}
1074
1075
this._store.delete(listener);
1076
});
1077
this._store.add(listener);
1078
1079
const entriesArr = [...this._entriesObs.get(), entry];
1080
this._entriesObs.set(entriesArr, undefined);
1081
1082
return entry;
1083
}
1084
1085
private async _createModifiedFileEntry(resource: URI, telemetryInfo: IModifiedEntryTelemetryInfo, ifNotExists: NotExistBehavior.Create, initialContent: string | undefined): Promise<AbstractChatEditingModifiedFileEntry>;
1086
private async _createModifiedFileEntry(resource: URI, telemetryInfo: IModifiedEntryTelemetryInfo, ifNotExists: NotExistBehavior, initialContent: string | undefined): Promise<AbstractChatEditingModifiedFileEntry | undefined>;
1087
1088
private async _createModifiedFileEntry(resource: URI, telemetryInfo: IModifiedEntryTelemetryInfo, ifNotExists: NotExistBehavior, initialContent: string | undefined): Promise<AbstractChatEditingModifiedFileEntry | undefined> {
1089
const multiDiffEntryDelegate = {
1090
collapse: (transaction: ITransaction | undefined) => this._collapse(resource, transaction),
1091
recordOperation: (operation: Mutable<FileOperation>) => {
1092
operation.epoch = this._timeline.incrementEpoch();
1093
this._timeline.recordFileOperation(operation);
1094
},
1095
};
1096
const notebookUri = CellUri.parse(resource)?.notebook || resource;
1097
const doCreate = async (chatKind: ChatEditKind) => {
1098
if (this._notebookService.hasSupportedNotebooks(notebookUri)) {
1099
return await ChatEditingModifiedNotebookEntry.create(notebookUri, multiDiffEntryDelegate, telemetryInfo, chatKind, initialContent, this._instantiationService);
1100
} else {
1101
const ref = await this._textModelService.createModelReference(resource);
1102
return this._instantiationService.createInstance(ChatEditingModifiedDocumentEntry, ref, multiDiffEntryDelegate, telemetryInfo, chatKind, initialContent);
1103
}
1104
};
1105
1106
try {
1107
return await doCreate(ChatEditKind.Modified);
1108
} catch (err) {
1109
if (ifNotExists === NotExistBehavior.Abort) {
1110
return undefined;
1111
}
1112
1113
// this file does not exist yet, create it and try again
1114
await this._bulkEditService.apply({ edits: [{ newResource: resource }] });
1115
if (this.configurationService.getValue<boolean>('accessibility.openChatEditedFiles')) {
1116
this._editorService.openEditor({ resource, options: { inactive: true, preserveFocus: true, pinned: true } });
1117
}
1118
1119
// Record file creation operation
1120
this._timeline.recordFileOperation({
1121
type: FileOperationType.Create,
1122
uri: resource,
1123
requestId: telemetryInfo.requestId,
1124
epoch: this._timeline.incrementEpoch(),
1125
initialContent: initialContent || '',
1126
telemetryInfo,
1127
});
1128
1129
if (this._notebookService.hasSupportedNotebooks(notebookUri)) {
1130
return await ChatEditingModifiedNotebookEntry.create(resource, multiDiffEntryDelegate, telemetryInfo, ChatEditKind.Created, initialContent, this._instantiationService);
1131
} else {
1132
return await doCreate(ChatEditKind.Created);
1133
}
1134
}
1135
}
1136
1137
private _collapse(resource: URI, transaction: ITransaction | undefined) {
1138
const multiDiffItem = this._editorPane?.findDocumentDiffItem(resource);
1139
if (multiDiffItem) {
1140
this._editorPane?.viewModel?.items.get().find((documentDiffItem) =>
1141
isEqual(documentDiffItem.originalUri, multiDiffItem.originalUri) &&
1142
isEqual(documentDiffItem.modifiedUri, multiDiffItem.modifiedUri))
1143
?.collapsed.set(true, transaction);
1144
}
1145
}
1146
}
1147
1148