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