Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingServiceImpl.ts
5251 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 { compareBy, delta } from '../../../../../base/common/arrays.js';
7
import { CancellationToken } from '../../../../../base/common/cancellation.js';
8
import { Codicon } from '../../../../../base/common/codicons.js';
9
import { groupBy } from '../../../../../base/common/collections.js';
10
import { ErrorNoTelemetry } from '../../../../../base/common/errors.js';
11
import { Emitter, Event } from '../../../../../base/common/event.js';
12
import { Iterable } from '../../../../../base/common/iterator.js';
13
import { Disposable, DisposableStore, dispose, IDisposable } from '../../../../../base/common/lifecycle.js';
14
import { LinkedList } from '../../../../../base/common/linkedList.js';
15
import { ResourceMap } from '../../../../../base/common/map.js';
16
import { Schemas } from '../../../../../base/common/network.js';
17
import { derived, IObservable, observableValueOpts, runOnChange, ValueWithChangeEventFromObservable } from '../../../../../base/common/observable.js';
18
import { isEqual } from '../../../../../base/common/resources.js';
19
import { compare } from '../../../../../base/common/strings.js';
20
import { ThemeIcon } from '../../../../../base/common/themables.js';
21
import { assertType } from '../../../../../base/common/types.js';
22
import { URI } from '../../../../../base/common/uri.js';
23
import { TextEdit } from '../../../../../editor/common/languages.js';
24
import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';
25
import { localize } from '../../../../../nls.js';
26
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
27
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
28
import { IFileService } from '../../../../../platform/files/common/files.js';
29
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
30
import { ILogService } from '../../../../../platform/log/common/log.js';
31
import { IProductService } from '../../../../../platform/product/common/productService.js';
32
import { IStorageService } from '../../../../../platform/storage/common/storage.js';
33
import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../services/decorations/common/decorations.js';
34
import { IEditorService } from '../../../../services/editor/common/editorService.js';
35
import { IExtensionService } from '../../../../services/extensions/common/extensions.js';
36
import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js';
37
import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js';
38
import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js';
39
import { INotebookService } from '../../../notebook/common/notebookService.js';
40
import { CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, inChatEditingSessionContextKey, IStreamingEdits, ModifiedFileEntryState, parseChatMultiDiffUri } from '../../common/editing/chatEditingService.js';
41
import { ChatModel, ICellTextEditOperation, IChatResponseModel, isCellTextEditOperationArray } from '../../common/model/chatModel.js';
42
import { IChatService } from '../../common/chatService/chatService.js';
43
import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js';
44
import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js';
45
import { ChatEditingSession } from './chatEditingSession.js';
46
import { ChatEditingSnapshotTextModelContentProvider, ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js';
47
48
export class ChatEditingService extends Disposable implements IChatEditingService {
49
50
_serviceBrand: undefined;
51
52
53
private readonly _sessionsObs = observableValueOpts<LinkedList<ChatEditingSession>>({ equalsFn: (a, b) => false }, new LinkedList());
54
55
readonly editingSessionsObs: IObservable<readonly IChatEditingSession[]> = derived(r => {
56
const result = Array.from(this._sessionsObs.read(r));
57
return result;
58
});
59
60
constructor(
61
@IInstantiationService private readonly _instantiationService: IInstantiationService,
62
@IMultiDiffSourceResolverService multiDiffSourceResolverService: IMultiDiffSourceResolverService,
63
@ITextModelService textModelService: ITextModelService,
64
@IContextKeyService contextKeyService: IContextKeyService,
65
@IChatService private readonly _chatService: IChatService,
66
@IEditorService private readonly _editorService: IEditorService,
67
@IDecorationsService decorationsService: IDecorationsService,
68
@IFileService private readonly _fileService: IFileService,
69
@ILifecycleService private readonly lifecycleService: ILifecycleService,
70
@IStorageService storageService: IStorageService,
71
@ILogService logService: ILogService,
72
@IExtensionService extensionService: IExtensionService,
73
@IProductService productService: IProductService,
74
@INotebookService private readonly notebookService: INotebookService,
75
@IConfigurationService private readonly _configurationService: IConfigurationService,
76
) {
77
super();
78
this._register(decorationsService.registerDecorationsProvider(_instantiationService.createInstance(ChatDecorationsProvider, this.editingSessionsObs)));
79
this._register(multiDiffSourceResolverService.registerResolver(_instantiationService.createInstance(ChatEditingMultiDiffSourceResolver, this.editingSessionsObs)));
80
81
// TODO@jrieken
82
// some ugly casting so that this service can pass itself as argument instad as service dependeny
83
// eslint-disable-next-line local/code-no-any-casts, @typescript-eslint/no-explicit-any
84
this._register(textModelService.registerTextModelContentProvider(ChatEditingTextModelContentProvider.scheme, _instantiationService.createInstance(ChatEditingTextModelContentProvider as any, this)));
85
// eslint-disable-next-line local/code-no-any-casts, @typescript-eslint/no-explicit-any
86
this._register(textModelService.registerTextModelContentProvider(Schemas.chatEditingSnapshotScheme, _instantiationService.createInstance(ChatEditingSnapshotTextModelContentProvider as any, this)));
87
88
this._register(this._chatService.onDidDisposeSession((e) => {
89
if (e.reason === 'cleared') {
90
for (const resource of e.sessionResource) {
91
this.getEditingSession(resource)?.stop();
92
}
93
}
94
}));
95
96
// todo@connor4312: temporary until chatReadonlyPromptReference proposal is finalized
97
const readonlyEnabledContextKey = chatEditingAgentSupportsReadonlyReferencesContextKey.bindTo(contextKeyService);
98
const setReadonlyFilesEnabled = () => {
99
const enabled = productService.quality !== 'stable' && extensionService.extensions.some(e => e.enabledApiProposals?.includes('chatReadonlyPromptReference'));
100
readonlyEnabledContextKey.set(enabled);
101
};
102
setReadonlyFilesEnabled();
103
this._register(extensionService.onDidRegisterExtensions(setReadonlyFilesEnabled));
104
this._register(extensionService.onDidChangeExtensions(setReadonlyFilesEnabled));
105
106
107
// eslint-disable-next-line @typescript-eslint/no-explicit-any
108
let storageTask: Promise<any> | undefined;
109
110
this._register(storageService.onWillSaveState(() => {
111
// eslint-disable-next-line @typescript-eslint/no-explicit-any
112
const tasks: Promise<any>[] = [];
113
114
for (const session of this.editingSessionsObs.get()) {
115
if (!session.isGlobalEditingSession) {
116
continue;
117
}
118
tasks.push((session as ChatEditingSession).storeState());
119
}
120
121
storageTask = Promise.resolve(storageTask)
122
.then(() => Promise.all(tasks))
123
.finally(() => storageTask = undefined);
124
}));
125
126
this._register(this.lifecycleService.onWillShutdown(e => {
127
if (!storageTask) {
128
return;
129
}
130
e.join(storageTask, {
131
id: 'join.chatEditingSession',
132
label: localize('join.chatEditingSession', "Saving chat edits history")
133
});
134
}));
135
}
136
137
override dispose(): void {
138
dispose(this._sessionsObs.get());
139
super.dispose();
140
}
141
142
startOrContinueGlobalEditingSession(chatModel: ChatModel): IChatEditingSession {
143
return this.getEditingSession(chatModel.sessionResource) || this.createEditingSession(chatModel, true);
144
}
145
146
private _lookupEntry(uri: URI): AbstractChatEditingModifiedFileEntry | undefined {
147
148
for (const item of Iterable.concat(this.editingSessionsObs.get())) {
149
const candidate = item.getEntry(uri);
150
if (candidate instanceof AbstractChatEditingModifiedFileEntry) {
151
// make sure to ref-count this object
152
return candidate.acquire();
153
}
154
}
155
return undefined;
156
}
157
158
getEditingSession(chatSessionResource: URI): IChatEditingSession | undefined {
159
return this.editingSessionsObs.get()
160
.find(candidate => isEqual(candidate.chatSessionResource, chatSessionResource));
161
}
162
163
createEditingSession(chatModel: ChatModel, global: boolean = false): IChatEditingSession {
164
return this._createEditingSession(chatModel, global, undefined);
165
}
166
167
transferEditingSession(chatModel: ChatModel, session: IChatEditingSession): IChatEditingSession {
168
return this._createEditingSession(chatModel, session.isGlobalEditingSession, session);
169
}
170
171
private _createEditingSession(chatModel: ChatModel, global: boolean, initFrom: IChatEditingSession | undefined): IChatEditingSession {
172
173
assertType(this.getEditingSession(chatModel.sessionResource) === undefined, 'CANNOT have more than one editing session per chat session');
174
175
const session = this._instantiationService.createInstance(ChatEditingSession, chatModel.sessionResource, global, this._lookupEntry.bind(this), initFrom);
176
177
const list = this._sessionsObs.get();
178
const removeSession = list.unshift(session);
179
180
const store = new DisposableStore();
181
this._store.add(store);
182
183
store.add(this.installAutoApplyObserver(session, chatModel));
184
185
store.add(session.onDidDispose(e => {
186
removeSession();
187
this._sessionsObs.set(list, undefined);
188
this._store.delete(store);
189
}));
190
191
this._sessionsObs.set(list, undefined);
192
193
return session;
194
}
195
196
private installAutoApplyObserver(session: ChatEditingSession, chatModel: ChatModel): IDisposable {
197
if (!chatModel) {
198
throw new ErrorNoTelemetry(`Edit session was created for a non-existing chat session: ${session.chatSessionResource}`);
199
}
200
201
const observerDisposables = new DisposableStore();
202
203
observerDisposables.add(chatModel.onDidChange(async e => {
204
if (e.kind !== 'addRequest') {
205
return;
206
}
207
session.createSnapshot(e.request.id, undefined);
208
const responseModel = e.request.response;
209
if (responseModel) {
210
this.observerEditsInResponse(e.request.id, responseModel, session, observerDisposables);
211
}
212
}));
213
observerDisposables.add(chatModel.onDidDispose(() => observerDisposables.dispose()));
214
return observerDisposables;
215
}
216
217
private observerEditsInResponse(requestId: string, responseModel: IChatResponseModel, session: ChatEditingSession, observerDisposables: DisposableStore) {
218
// Sparse array: the indicies are indexes of `responseModel.response.value`
219
// that are edit groups, and then this tracks the edit application for
220
// each of them. Note that text edit groups can be updated
221
// multiple times during the process of response streaming.
222
const enum K { Stream, Workspace }
223
const editsSeen: ({ kind: K.Stream; seen: number; stream: IStreamingEdits } | { kind: K.Workspace })[] = [];
224
225
let editorDidChange = false;
226
const editorListener = Event.once(this._editorService.onDidActiveEditorChange)(() => {
227
editorDidChange = true;
228
});
229
const editorOpenPromises = new ResourceMap<Promise<void>>();
230
const openChatEditedFiles = this._configurationService.getValue('accessibility.openChatEditedFiles');
231
232
const ensureEditorOpen = (partUri: URI) => {
233
const uri = CellUri.parse(partUri)?.notebook ?? partUri;
234
if (editorOpenPromises.has(uri)) {
235
return;
236
}
237
editorOpenPromises.set(uri, (async () => {
238
if (this.notebookService.getNotebookTextModel(uri) || uri.scheme === Schemas.untitled || await this._fileService.exists(uri).catch(() => false)) {
239
const activeUri = this._editorService.activeEditorPane?.input.resource;
240
const inactive = editorDidChange
241
|| this._editorService.activeEditorPane?.input instanceof ChatEditorInput && isEqual(this._editorService.activeEditorPane.input.sessionResource, session.chatSessionResource)
242
|| Boolean(activeUri && session.entries.get().find(entry => isEqual(activeUri, entry.modifiedURI)));
243
244
this._editorService.openEditor({ resource: uri, options: { inactive, preserveFocus: true, pinned: true } });
245
}
246
})());
247
};
248
249
const onResponseComplete = () => {
250
for (const remaining of editsSeen) {
251
if (remaining?.kind === K.Stream) {
252
remaining.stream.complete();
253
}
254
}
255
256
editsSeen.length = 0;
257
editorOpenPromises.clear();
258
editorListener.dispose();
259
};
260
261
const handleResponseParts = async () => {
262
if (responseModel.isCanceled) {
263
return;
264
}
265
266
let undoStop: undefined | string;
267
for (let i = 0; i < responseModel.response.value.length; i++) {
268
const part = responseModel.response.value[i];
269
270
if (part.kind === 'undoStop') {
271
undoStop = part.id;
272
continue;
273
}
274
275
if (part.kind === 'workspaceEdit') {
276
// Track if we've already started processing this workspace edit
277
if (!editsSeen[i]) {
278
editsSeen[i] = { kind: K.Workspace };
279
session.applyWorkspaceEdit(part, responseModel, undoStop ?? responseModel.requestId);
280
}
281
continue;
282
}
283
284
if (part.kind !== 'textEditGroup' && part.kind !== 'notebookEditGroup') {
285
continue;
286
}
287
288
// Skip external edits - they're already applied on disk
289
if (part.isExternalEdit) {
290
continue;
291
}
292
293
if (openChatEditedFiles) {
294
ensureEditorOpen(part.uri);
295
}
296
297
// get new edits and start editing session
298
let entry = editsSeen[i];
299
if (!entry) {
300
entry = { kind: K.Stream, seen: 0, stream: session.startStreamingEdits(CellUri.parse(part.uri)?.notebook ?? part.uri, responseModel, undoStop) };
301
editsSeen[i] = entry;
302
}
303
304
if (entry.kind !== K.Stream) {
305
continue;
306
}
307
308
const isFirst = entry.seen === 0;
309
const newEdits = part.edits.slice(entry.seen);
310
entry.seen = part.edits.length;
311
312
if (newEdits.length > 0 || isFirst) {
313
for (let i = 0; i < newEdits.length; i++) {
314
const edit = newEdits[i];
315
const done = part.done ? i === newEdits.length - 1 : false;
316
317
if (isTextEditOperationArray(edit)) {
318
entry.stream.pushText(edit, done);
319
} else if (isCellTextEditOperationArray(edit)) {
320
for (const edits of Object.values(groupBy(edit, e => e.uri.toString()))) {
321
if (edits) {
322
entry.stream.pushNotebookCellText(edits[0].uri, edits.map(e => e.edit), done);
323
}
324
}
325
} else {
326
entry.stream.pushNotebook(edit, done);
327
}
328
}
329
}
330
331
if (part.done) {
332
entry.stream.complete();
333
}
334
}
335
};
336
337
if (responseModel.isComplete) {
338
handleResponseParts().then(() => {
339
onResponseComplete();
340
});
341
} else {
342
const disposable = observerDisposables.add(responseModel.onDidChange(e2 => {
343
if (e2.reason === 'undoStop') {
344
session.createSnapshot(requestId, e2.id);
345
} else {
346
handleResponseParts().then(() => {
347
if (responseModel.isComplete) {
348
onResponseComplete();
349
observerDisposables.delete(disposable);
350
}
351
});
352
}
353
}));
354
}
355
}
356
}
357
358
/**
359
* Emits an event containing the added or removed elements of the observable.
360
*/
361
function observeArrayChanges<T>(obs: IObservable<T[]>, compare: (a: T, b: T) => number, store: DisposableStore): Event<T[]> {
362
const emitter = store.add(new Emitter<T[]>());
363
store.add(runOnChange(obs, (newArr, oldArr) => {
364
const change = delta(oldArr || [], newArr, compare);
365
const changedElements = ([] as T[]).concat(change.added).concat(change.removed);
366
emitter.fire(changedElements);
367
}));
368
return emitter.event;
369
}
370
371
class ChatDecorationsProvider extends Disposable implements IDecorationsProvider {
372
373
readonly label: string = localize('chat', "Chat Editing");
374
375
private readonly _currentEntries = derived<readonly IModifiedFileEntry[]>(this, (r) => {
376
const sessions = this._sessions.read(r);
377
if (!sessions) {
378
return [];
379
}
380
const result: IModifiedFileEntry[] = [];
381
for (const session of sessions) {
382
if (session.state.read(r) !== ChatEditingSessionState.Disposed) {
383
const entries = session.entries.read(r);
384
result.push(...entries);
385
}
386
}
387
return result;
388
});
389
390
private readonly _currentlyEditingUris = derived<URI[]>(this, (r) => {
391
const uri = this._currentEntries.read(r);
392
return uri.filter(entry => entry.isCurrentlyBeingModifiedBy.read(r)).map(entry => entry.modifiedURI);
393
});
394
395
private readonly _modifiedUris = derived<URI[]>(this, (r) => {
396
const uri = this._currentEntries.read(r);
397
return uri.filter(entry => !entry.isCurrentlyBeingModifiedBy.read(r) && entry.state.read(r) === ModifiedFileEntryState.Modified).map(entry => entry.modifiedURI);
398
});
399
400
readonly onDidChange: Event<URI[]>;
401
402
constructor(
403
private readonly _sessions: IObservable<readonly IChatEditingSession[]>
404
) {
405
super();
406
this.onDidChange = Event.any(
407
observeArrayChanges(this._currentlyEditingUris, compareBy(uri => uri.toString(), compare), this._store),
408
observeArrayChanges(this._modifiedUris, compareBy(uri => uri.toString(), compare), this._store),
409
);
410
}
411
412
provideDecorations(uri: URI, _token: CancellationToken): IDecorationData | undefined {
413
const isCurrentlyBeingModified = this._currentlyEditingUris.get().some(e => e.toString() === uri.toString());
414
if (isCurrentlyBeingModified) {
415
return {
416
weight: 1000,
417
letter: ThemeIcon.modify(Codicon.loading, 'spin'),
418
bubble: false
419
};
420
}
421
const isModified = this._modifiedUris.get().some(e => e.toString() === uri.toString());
422
if (isModified) {
423
return {
424
weight: 1000,
425
letter: Codicon.diffModified,
426
tooltip: localize('chatEditing.modified2', "Pending changes from chat"),
427
bubble: true
428
};
429
}
430
return undefined;
431
}
432
}
433
434
export class ChatEditingMultiDiffSourceResolver implements IMultiDiffSourceResolver {
435
436
constructor(
437
private readonly _editingSessionsObs: IObservable<readonly IChatEditingSession[]>,
438
@IInstantiationService private readonly _instantiationService: IInstantiationService,
439
) { }
440
441
canHandleUri(uri: URI): boolean {
442
return uri.scheme === CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME;
443
}
444
445
async resolveDiffSource(uri: URI): Promise<IResolvedMultiDiffSource> {
446
447
const parsed = parseChatMultiDiffUri(uri);
448
const thisSession = derived(this, r => {
449
return this._editingSessionsObs.read(r).find(candidate => isEqual(candidate.chatSessionResource, parsed.chatSessionResource));
450
});
451
452
return this._instantiationService.createInstance(ChatEditingMultiDiffSource, thisSession, parsed.showPreviousChanges);
453
}
454
}
455
456
class ChatEditingMultiDiffSource implements IResolvedMultiDiffSource {
457
private readonly _resources = derived<readonly MultiDiffEditorItem[]>(this, (reader) => {
458
const currentSession = this._currentSession.read(reader);
459
if (!currentSession) {
460
return [];
461
}
462
const entries = currentSession.entries.read(reader);
463
return entries.map((entry) => {
464
if (this._showPreviousChanges) {
465
const entryDiffObs = currentSession.getEntryDiffBetweenStops(entry.modifiedURI, undefined, undefined);
466
const entryDiff = entryDiffObs?.read(reader);
467
if (entryDiff) {
468
return new MultiDiffEditorItem(
469
entryDiff.originalURI,
470
entryDiff.modifiedURI,
471
undefined,
472
undefined,
473
{
474
[chatEditingResourceContextKey.key]: entry.entryId,
475
},
476
);
477
}
478
}
479
480
return new MultiDiffEditorItem(
481
entry.originalURI,
482
entry.modifiedURI,
483
undefined,
484
undefined,
485
{
486
[chatEditingResourceContextKey.key]: entry.entryId,
487
// [inChatEditingSessionContextKey.key]: true
488
},
489
);
490
});
491
});
492
readonly resources = new ValueWithChangeEventFromObservable(this._resources);
493
494
readonly contextKeys = {
495
[inChatEditingSessionContextKey.key]: true
496
};
497
498
constructor(
499
private readonly _currentSession: IObservable<IChatEditingSession | undefined>,
500
private readonly _showPreviousChanges: boolean
501
) { }
502
}
503
504
function isTextEditOperationArray(value: TextEdit[] | ICellTextEditOperation[] | ICellEditOperation[]): value is TextEdit[] {
505
return value.some(e => TextEdit.isTextEdit(e));
506
}
507
508