Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTimeline.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
7
8
import { equals as arraysEqual, binarySearch2 } from '../../../../../base/common/arrays.js';
9
import { findLast } from '../../../../../base/common/arraysFind.js';
10
import { Iterable } from '../../../../../base/common/iterator.js';
11
import { DisposableStore, thenRegisterOrDispose } from '../../../../../base/common/lifecycle.js';
12
import { ResourceMap } from '../../../../../base/common/map.js';
13
import { equals as objectsEqual } from '../../../../../base/common/objects.js';
14
import { derived, derivedOpts, IObservable, ITransaction, ObservablePromise, observableValue, transaction } from '../../../../../base/common/observable.js';
15
import { isEqual } from '../../../../../base/common/resources.js';
16
import { URI } from '../../../../../base/common/uri.js';
17
import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js';
18
import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';
19
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
20
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
21
import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';
22
import { IEditSessionEntryDiff, ISnapshotEntry } from '../../common/chatEditingService.js';
23
import { IChatRequestDisablement } from '../../common/chatModel.js';
24
import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js';
25
import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js';
26
import { IChatEditingSessionSnapshot, IChatEditingSessionStop } from './chatEditingSessionStorage.js';
27
import { ChatEditingModifiedNotebookDiff } from './notebook/chatEditingModifiedNotebookDiff.js';
28
29
/**
30
* Timeline/undo-redo stack for ChatEditingSession.
31
*/
32
export class ChatEditingTimeline {
33
public static readonly POST_EDIT_STOP_ID = 'd19944f6-f46c-4e17-911b-79a8e843c7c0'; // randomly generated
34
public static createEmptySnapshot(undoStop: string | undefined): IChatEditingSessionStop {
35
return {
36
stopId: undoStop,
37
entries: new ResourceMap(),
38
};
39
}
40
41
private readonly _linearHistory = observableValue<readonly IChatEditingSessionSnapshot[]>(this, []);
42
private readonly _linearHistoryIndex = observableValue<number>(this, 0);
43
44
private readonly _diffsBetweenStops = new Map<string, IObservable<IEditSessionEntryDiff | undefined>>();
45
private readonly _fullDiffs = new Map<string, IObservable<IEditSessionEntryDiff | undefined>>();
46
private readonly _ignoreTrimWhitespaceObservable: IObservable<boolean>;
47
48
public readonly canUndo: IObservable<boolean>;
49
public readonly canRedo: IObservable<boolean>;
50
51
public readonly requestDisablement = derivedOpts<IChatRequestDisablement[]>({ equalsFn: (a, b) => arraysEqual(a, b, objectsEqual) }, reader => {
52
const history = this._linearHistory.read(reader);
53
const index = this._linearHistoryIndex.read(reader);
54
const undoRequests: IChatRequestDisablement[] = [];
55
for (const entry of history) {
56
if (!entry.requestId) {
57
// ignored
58
} else if (entry.startIndex >= index) {
59
undoRequests.push({ requestId: entry.requestId });
60
} else if (entry.startIndex + entry.stops.length > index) {
61
undoRequests.push({ requestId: entry.requestId, afterUndoStop: entry.stops[(index - 1) - entry.startIndex].stopId });
62
}
63
}
64
return undoRequests;
65
});
66
67
constructor(
68
@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,
69
@IInstantiationService private readonly _instantiationService: IInstantiationService,
70
@IConfigurationService configurationService: IConfigurationService,
71
@ITextModelService private readonly _textModelService: ITextModelService,
72
) {
73
this._ignoreTrimWhitespaceObservable = observableConfigValue('diffEditor.ignoreTrimWhitespace', true, configurationService);
74
75
this.canUndo = derived(r => {
76
const linearHistoryIndex = this._linearHistoryIndex.read(r);
77
return linearHistoryIndex > 1;
78
});
79
this.canRedo = derived(r => {
80
const linearHistoryIndex = this._linearHistoryIndex.read(r);
81
return linearHistoryIndex < getMaxHistoryIndex(this._linearHistory.read(r));
82
});
83
}
84
85
/**
86
* Restore the timeline from a saved state (history array and index).
87
*/
88
public restoreFromState(state: { history: readonly IChatEditingSessionSnapshot[]; index: number }, tx: ITransaction): void {
89
this._linearHistory.set(state.history, tx);
90
this._linearHistoryIndex.set(state.index, tx);
91
}
92
93
/**
94
* Get the snapshot and history index for restoring, given requestId and stopId.
95
* If requestId is undefined, returns undefined (pending snapshot is managed by session).
96
*/
97
public getSnapshotForRestore(requestId: string | undefined, stopId: string | undefined): { stop: IChatEditingSessionStop; apply(): void } | undefined {
98
if (requestId === undefined) {
99
return undefined;
100
}
101
const stopRef = this.findEditStop(requestId, stopId);
102
if (!stopRef) {
103
return undefined;
104
}
105
106
// When rolling back to the first snapshot taken for a request, mark the
107
// entire request as undone.
108
const toIndex = stopRef.stop.stopId === undefined ? stopRef.historyIndex : stopRef.historyIndex + 1;
109
return {
110
stop: stopRef.stop,
111
apply: () => this._linearHistoryIndex.set(toIndex, undefined)
112
};
113
}
114
115
/**
116
* Ensures the state of the file in the given snapshot matches the current
117
* state of the {@param entry}. This is used to handle concurrent file edits.
118
*
119
* Given the case of two different edits, we will place and undo stop right
120
* before we `textEditGroup` in the underlying markdown stream, but at the
121
* time those are added the edits haven't been made yet, so both files will
122
* simply have the unmodified state.
123
*
124
* This method is called after each edit, so after the first file finishes
125
* being edits, it will update its content in the second undo snapshot such
126
* that it can be undone successfully.
127
*
128
* We ensure that the same file is not concurrently edited via the
129
* {@link _streamingEditLocks}, avoiding race conditions.
130
*
131
* @param next If true, this will edit the snapshot _after_ the undo stop
132
*/
133
public ensureEditInUndoStopMatches(
134
requestId: string,
135
undoStop: string | undefined,
136
entry: Pick<AbstractChatEditingModifiedFileEntry, 'modifiedURI' | 'createSnapshot' | 'equalsSnapshot'>,
137
next: boolean,
138
tx: ITransaction | undefined
139
) {
140
const history = this._linearHistory.get();
141
const snapIndex = history.findIndex((s) => s.requestId === requestId);
142
if (snapIndex === -1) {
143
return;
144
}
145
146
const snap = { ...history[snapIndex] };
147
let stopIndex = snap.stops.findIndex((s) => s.stopId === undoStop);
148
if (stopIndex === -1) {
149
return;
150
}
151
152
let linearHistoryIndexIncr = 0;
153
if (next) {
154
if (stopIndex === snap.stops.length - 1) {
155
if (snap.stops[stopIndex].stopId === ChatEditingTimeline.POST_EDIT_STOP_ID) {
156
throw new Error('cannot duplicate post-edit stop');
157
}
158
159
snap.stops = snap.stops.concat(ChatEditingTimeline.createEmptySnapshot(ChatEditingTimeline.POST_EDIT_STOP_ID));
160
linearHistoryIndexIncr++;
161
}
162
stopIndex++;
163
}
164
165
const stop = snap.stops[stopIndex];
166
if (entry.equalsSnapshot(stop.entries.get(entry.modifiedURI))) {
167
return;
168
}
169
170
const newMap = new ResourceMap(stop.entries);
171
newMap.set(entry.modifiedURI, entry.createSnapshot(requestId, stop.stopId));
172
173
const newStop = snap.stops.slice();
174
newStop[stopIndex] = { ...stop, entries: newMap };
175
snap.stops = newStop;
176
177
const newHistory = history.slice();
178
newHistory[snapIndex] = snap;
179
180
this._linearHistory.set(newHistory, tx);
181
if (linearHistoryIndexIncr) {
182
this._linearHistoryIndex.set(this._linearHistoryIndex.get() + linearHistoryIndexIncr, tx);
183
}
184
}
185
186
/**
187
* Get the undo snapshot (previous in history), or undefined if at start.
188
* If the timeline is at the end of the history, it will return the last stop
189
* pushed into the history.
190
*/
191
public getUndoSnapshot(): { stop: IChatEditingSessionStop; apply(): void } | undefined {
192
return this.getUndoRedoSnapshot(-1);
193
}
194
195
/**
196
* Get the redo snapshot (next in history), or undefined if at end.
197
*/
198
public getRedoSnapshot(): { stop: IChatEditingSessionStop; apply(): void } | undefined {
199
return this.getUndoRedoSnapshot(1);
200
}
201
202
private getUndoRedoSnapshot(direction: number) {
203
let idx = this._linearHistoryIndex.get() - 1;
204
const max = getMaxHistoryIndex(this._linearHistory.get());
205
const startEntry = this.getHistoryEntryByLinearIndex(idx);
206
let entry = startEntry;
207
if (!startEntry) {
208
return undefined;
209
}
210
211
do {
212
idx += direction;
213
entry = this.getHistoryEntryByLinearIndex(idx);
214
} while (
215
idx + direction < max &&
216
idx + direction >= 0 &&
217
entry &&
218
!(direction === -1 && entry.entry.requestId !== startEntry.entry.requestId) &&
219
!stopProvidesNewData(startEntry.stop, entry.stop)
220
);
221
222
if (entry) {
223
return { stop: entry.stop, apply: () => this._linearHistoryIndex.set(idx + 1, undefined) };
224
}
225
226
return undefined;
227
}
228
229
/**
230
* Get the state for persistence (history and index).
231
*/
232
public getStateForPersistence(): { history: readonly IChatEditingSessionSnapshot[]; index: number } {
233
return { history: this._linearHistory.get(), index: this._linearHistoryIndex.get() };
234
}
235
236
private findSnapshot(requestId: string): IChatEditingSessionSnapshot | undefined {
237
return this._linearHistory.get().find((s) => s.requestId === requestId);
238
}
239
240
private findEditStop(requestId: string, undoStop: string | undefined) {
241
const snapshot = this.findSnapshot(requestId);
242
if (!snapshot) {
243
return undefined;
244
}
245
const idx = snapshot.stops.findIndex((s) => s.stopId === undoStop);
246
return idx === -1 ? undefined : { stop: snapshot.stops[idx], snapshot, historyIndex: snapshot.startIndex + idx };
247
}
248
249
private getHistoryEntryByLinearIndex(index: number) {
250
const history = this._linearHistory.get();
251
const searchedIndex = binarySearch2(history.length, (e) => history[e].startIndex - index);
252
const entry = history[searchedIndex < 0 ? (~searchedIndex) - 1 : searchedIndex];
253
if (!entry || index - entry.startIndex >= entry.stops.length) {
254
return undefined;
255
}
256
return {
257
entry,
258
stop: entry.stops[index - entry.startIndex]
259
};
260
}
261
262
public pushSnapshot(requestId: string, undoStop: string | undefined, snapshot: IChatEditingSessionStop) {
263
const linearHistoryPtr = this._linearHistoryIndex.get();
264
const newLinearHistory: IChatEditingSessionSnapshot[] = [];
265
for (const entry of this._linearHistory.get()) {
266
if (entry.startIndex >= linearHistoryPtr) {
267
break;
268
} else if (linearHistoryPtr - entry.startIndex < entry.stops.length) {
269
newLinearHistory.push({ requestId: entry.requestId, stops: entry.stops.slice(0, linearHistoryPtr - entry.startIndex), startIndex: entry.startIndex });
270
} else {
271
newLinearHistory.push(entry);
272
}
273
}
274
275
const lastEntry = newLinearHistory.at(-1);
276
if (requestId && lastEntry?.requestId === requestId) {
277
const hadPostEditStop = lastEntry.stops.at(-1)?.stopId === ChatEditingTimeline.POST_EDIT_STOP_ID && undoStop;
278
if (hadPostEditStop) {
279
const rebaseUri = (uri: URI) => uri.with({ query: uri.query.replace(ChatEditingTimeline.POST_EDIT_STOP_ID, undoStop) });
280
for (const [uri, prev] of lastEntry.stops.at(-1)!.entries) {
281
snapshot.entries.set(uri, { ...prev, snapshotUri: rebaseUri(prev.snapshotUri), resource: rebaseUri(prev.resource) });
282
}
283
}
284
newLinearHistory[newLinearHistory.length - 1] = {
285
...lastEntry,
286
stops: [...hadPostEditStop ? lastEntry.stops.slice(0, -1) : lastEntry.stops, snapshot]
287
};
288
} else {
289
newLinearHistory.push({ requestId, startIndex: lastEntry ? lastEntry.startIndex + lastEntry.stops.length : 0, stops: [snapshot] });
290
}
291
292
transaction((tx) => {
293
const last = newLinearHistory[newLinearHistory.length - 1];
294
this._linearHistory.set(newLinearHistory, tx);
295
this._linearHistoryIndex.set(last.startIndex + last.stops.length, tx);
296
});
297
}
298
299
/**
300
* Gets diff for text entries between stops.
301
* @param entriesContent Observable that observes either snapshot entry
302
* @param modelUrisObservable Observable that observes only the snapshot URIs.
303
*/
304
private _entryDiffBetweenTextStops(
305
entriesContent: IObservable<{ before: ISnapshotEntry; after: ISnapshotEntry } | undefined>,
306
modelUrisObservable: IObservable<[URI, URI] | undefined>,
307
): IObservable<ObservablePromise<IEditSessionEntryDiff> | undefined> {
308
const modelRefsPromise = derived(this, (reader) => {
309
const modelUris = modelUrisObservable.read(reader);
310
if (!modelUris) { return undefined; }
311
312
const store = reader.store.add(new DisposableStore());
313
const promise = Promise.all(modelUris.map(u => this._textModelService.createModelReference(u))).then(refs => {
314
if (store.isDisposed) {
315
refs.forEach(r => r.dispose());
316
} else {
317
refs.forEach(r => store.add(r));
318
}
319
320
return refs;
321
});
322
323
return new ObservablePromise(promise);
324
});
325
326
return derived((reader): ObservablePromise<IEditSessionEntryDiff> | undefined => {
327
const refs2 = modelRefsPromise.read(reader)?.promiseResult.read(reader);
328
const refs = refs2?.data;
329
if (!refs) {
330
return;
331
}
332
333
const entries = entriesContent.read(reader); // trigger re-diffing when contents change
334
335
if (entries?.before && ChatEditingModifiedNotebookEntry.canHandleSnapshot(entries.before)) {
336
const diffService = this._instantiationService.createInstance(ChatEditingModifiedNotebookDiff, entries.before, entries.after);
337
return new ObservablePromise(diffService.computeDiff());
338
339
}
340
const ignoreTrimWhitespace = this._ignoreTrimWhitespaceObservable.read(reader);
341
const promise = this._computeDiff(refs[0].object.textEditorModel.uri, refs[1].object.textEditorModel.uri, ignoreTrimWhitespace);
342
343
return new ObservablePromise(promise);
344
});
345
}
346
347
private _createDiffBetweenStopsObservable(uri: URI, requestId: string | undefined, stopId: string | undefined): IObservable<IEditSessionEntryDiff | undefined> {
348
const entries = derivedOpts<undefined | { before: ISnapshotEntry; after: ISnapshotEntry }>(
349
{
350
equalsFn: (a, b) => snapshotsEqualForDiff(a?.before, b?.before) && snapshotsEqualForDiff(a?.after, b?.after),
351
},
352
reader => {
353
const stops = requestId ?
354
getCurrentAndNextStop(requestId, stopId, this._linearHistory.read(reader)) :
355
getFirstAndLastStop(uri, this._linearHistory.read(reader));
356
if (!stops) { return undefined; }
357
const before = stops.current.get(uri);
358
const after = stops.next.get(uri);
359
if (!before || !after) { return undefined; }
360
return { before, after };
361
},
362
);
363
364
// Separate observable for model refs to avoid unnecessary disposal
365
const modelUrisObservable = derivedOpts<[URI, URI] | undefined>({ equalsFn: (a, b) => arraysEqual(a, b, isEqual) }, reader => {
366
const entriesValue = entries.read(reader);
367
if (!entriesValue) { return undefined; }
368
return [entriesValue.before.snapshotUri, entriesValue.after.snapshotUri];
369
});
370
371
const diff = this._entryDiffBetweenTextStops(entries, modelUrisObservable);
372
373
return derived(reader => {
374
return diff.read(reader)?.promiseResult.read(reader)?.data || undefined;
375
});
376
}
377
378
public getEntryDiffBetweenStops(uri: URI, requestId: string | undefined, stopId: string | undefined) {
379
if (requestId) {
380
const key = `${uri}\0${requestId}\0${stopId}`;
381
let observable = this._diffsBetweenStops.get(key);
382
if (!observable) {
383
observable = this._createDiffBetweenStopsObservable(uri, requestId, stopId);
384
this._diffsBetweenStops.set(key, observable);
385
}
386
387
return observable;
388
} else {
389
const key = uri.toString();
390
let observable = this._fullDiffs.get(key);
391
if (!observable) {
392
observable = this._createDiffBetweenStopsObservable(uri, requestId, stopId);
393
this._fullDiffs.set(key, observable);
394
}
395
396
return observable;
397
}
398
}
399
400
public getEntryDiffBetweenRequests(uri: URI, startRequestId: string, stopRequestId: string): IObservable<IEditSessionEntryDiff | undefined> {
401
const snapshotUris = derivedOpts<[URI | undefined, URI | undefined]>(
402
{ equalsFn: (a, b) => arraysEqual(a, b, isEqual) },
403
reader => {
404
const history = this._linearHistory.read(reader);
405
const firstSnapshotUri = this._getFirstSnapshotForUriAfterRequest(history, uri, startRequestId, true);
406
const lastSnapshotUri = this._getFirstSnapshotForUriAfterRequest(history, uri, stopRequestId, false);
407
return [firstSnapshotUri, lastSnapshotUri];
408
},
409
);
410
const modelRefs = derived((reader) => {
411
const snapshots = snapshotUris.read(reader);
412
const firstSnapshotUri = snapshots[0];
413
const lastSnapshotUri = snapshots[1];
414
if (!firstSnapshotUri || !lastSnapshotUri) {
415
return;
416
}
417
const store = new DisposableStore();
418
reader.store.add(store);
419
const referencesPromise = Promise.all([firstSnapshotUri, lastSnapshotUri].map(u => {
420
return thenRegisterOrDispose(this._textModelService.createModelReference(u), store);
421
}));
422
return new ObservablePromise(referencesPromise);
423
});
424
const diff = derived((reader): ObservablePromise<IEditSessionEntryDiff> | undefined => {
425
const references = modelRefs.read(reader)?.promiseResult.read(reader);
426
const refs = references?.data;
427
if (!refs) {
428
return;
429
}
430
const ignoreTrimWhitespace = this._ignoreTrimWhitespaceObservable.read(reader);
431
const promise = this._computeDiff(refs[0].object.textEditorModel.uri, refs[1].object.textEditorModel.uri, ignoreTrimWhitespace);
432
return new ObservablePromise(promise);
433
});
434
return derived(reader => {
435
return diff.read(reader)?.promiseResult.read(reader)?.data || undefined;
436
});
437
}
438
439
private _computeDiff(originalUri: URI, modifiedUri: URI, ignoreTrimWhitespace: boolean): Promise<IEditSessionEntryDiff> {
440
return this._editorWorkerService.computeDiff(
441
originalUri,
442
modifiedUri,
443
{ ignoreTrimWhitespace, computeMoves: false, maxComputationTimeMs: 3000 },
444
'advanced'
445
).then((diff): IEditSessionEntryDiff => {
446
const entryDiff: IEditSessionEntryDiff = {
447
originalURI: originalUri,
448
modifiedURI: modifiedUri,
449
identical: !!diff?.identical,
450
quitEarly: !diff || diff.quitEarly,
451
added: 0,
452
removed: 0,
453
};
454
if (diff) {
455
for (const change of diff.changes) {
456
entryDiff.removed += change.original.endLineNumberExclusive - change.original.startLineNumber;
457
entryDiff.added += change.modified.endLineNumberExclusive - change.modified.startLineNumber;
458
}
459
}
460
return entryDiff;
461
});
462
}
463
464
private _getFirstSnapshotForUriAfterRequest(history: readonly IChatEditingSessionSnapshot[], uri: URI, requestId: string, inclusive: boolean): URI | undefined {
465
const requestIndex = history.findIndex(s => s.requestId === requestId);
466
if (requestIndex === -1) { return undefined; }
467
const processedIndex = requestIndex + (inclusive ? 0 : 1);
468
for (let i = processedIndex; i < history.length; i++) {
469
const snapshot = history[i];
470
for (const stop of snapshot.stops) {
471
const entry = stop.entries.get(uri);
472
if (entry) {
473
return entry.snapshotUri;
474
}
475
}
476
}
477
return uri;
478
}
479
}
480
481
function stopProvidesNewData(origin: IChatEditingSessionStop, target: IChatEditingSessionStop) {
482
return Iterable.some(target.entries, ([uri, e]) => origin.entries.get(uri)?.current !== e.current);
483
}
484
485
function getMaxHistoryIndex(history: readonly IChatEditingSessionSnapshot[]) {
486
const lastHistory = history.at(-1);
487
return lastHistory ? lastHistory.startIndex + lastHistory.stops.length : 0;
488
}
489
490
function snapshotsEqualForDiff(a: ISnapshotEntry | undefined, b: ISnapshotEntry | undefined) {
491
if (!a || !b) {
492
return a === b;
493
}
494
495
return isEqual(a.snapshotUri, b.snapshotUri) && a.current === b.current;
496
}
497
498
function getCurrentAndNextStop(requestId: string, stopId: string | undefined, history: readonly IChatEditingSessionSnapshot[]) {
499
const snapshotIndex = history.findIndex(s => s.requestId === requestId);
500
if (snapshotIndex === -1) { return undefined; }
501
const snapshot = history[snapshotIndex];
502
const stopIndex = snapshot.stops.findIndex(s => s.stopId === stopId);
503
if (stopIndex === -1) { return undefined; }
504
505
const currentStop = snapshot.stops[stopIndex];
506
const current = currentStop.entries;
507
const nextStop = stopIndex < snapshot.stops.length - 1
508
? snapshot.stops[stopIndex + 1]
509
: undefined;
510
if (!nextStop) {
511
return undefined;
512
}
513
514
return { current, currentStopId: currentStop.stopId, next: nextStop.entries, nextStopId: nextStop.stopId };
515
}
516
517
function getFirstAndLastStop(uri: URI, history: readonly IChatEditingSessionSnapshot[]) {
518
let firstStopWithUri: IChatEditingSessionStop | undefined;
519
for (const snapshot of history) {
520
const stop = snapshot.stops.find(s => s.entries.has(uri));
521
if (stop) {
522
firstStopWithUri = stop;
523
break;
524
}
525
}
526
527
let lastStopWithUri: ResourceMap<ISnapshotEntry> | undefined;
528
let lastStopWithUriId: string | undefined;
529
for (let i = history.length - 1; i >= 0; i--) {
530
const snapshot = history[i];
531
const stop = findLast(snapshot.stops, s => s.entries.has(uri));
532
if (stop) {
533
lastStopWithUri = stop.entries;
534
lastStopWithUriId = stop.stopId;
535
break;
536
}
537
}
538
539
if (!firstStopWithUri || !lastStopWithUri) {
540
return undefined;
541
}
542
543
return { current: firstStopWithUri.entries, currentStopId: firstStopWithUri.stopId, next: lastStopWithUri, nextStopId: lastStopWithUriId! };
544
}
545
546