Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCheckpointTimelineImpl.ts
4780 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 { equals as arraysEqual } from '../../../../../base/common/arrays.js';
7
import { findFirst, findLast, findLastIdx } from '../../../../../base/common/arraysFind.js';
8
import { assertNever } from '../../../../../base/common/assert.js';
9
import { ThrottledDelayer } from '../../../../../base/common/async.js';
10
import { Event } from '../../../../../base/common/event.js';
11
import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js';
12
import { mapsStrictEqualIgnoreOrder, ResourceMap, ResourceSet } from '../../../../../base/common/map.js';
13
import { equals as objectsEqual } from '../../../../../base/common/objects.js';
14
import { constObservable, derived, derivedOpts, IObservable, IReader, ITransaction, ObservablePromise, observableSignalFromEvent, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js';
15
import { isEqual } from '../../../../../base/common/resources.js';
16
import { isDefined, Mutable } from '../../../../../base/common/types.js';
17
import { URI } from '../../../../../base/common/uri.js';
18
import { generateUuid } from '../../../../../base/common/uuid.js';
19
import { TextEdit } from '../../../../../editor/common/languages.js';
20
import { ITextModel } from '../../../../../editor/common/model.js';
21
import { TextModel } from '../../../../../editor/common/model/textModel.js';
22
import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js';
23
import { IModelService } from '../../../../../editor/common/services/model.js';
24
import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';
25
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
26
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
27
import { CellEditType, CellUri, INotebookTextModel } from '../../../notebook/common/notebookCommon.js';
28
import { INotebookEditorModelResolverService } from '../../../notebook/common/notebookEditorModelResolverService.js';
29
import { INotebookService } from '../../../notebook/common/notebookService.js';
30
import { emptySessionEntryDiff, IEditSessionDiffStats, IEditSessionEntryDiff, IModifiedEntryTelemetryInfo } from '../../common/editing/chatEditingService.js';
31
import { IChatRequestDisablement } from '../../common/model/chatModel.js';
32
import { IChatEditingCheckpointTimeline } from './chatEditingCheckpointTimeline.js';
33
import { FileOperation, FileOperationType, IChatEditingTimelineState, ICheckpoint, IFileBaseline, IReconstructedFileExistsState, IReconstructedFileNotExistsState, IReconstructedFileState } from './chatEditingOperations.js';
34
import { ChatEditingSnapshotTextModelContentProvider } from './chatEditingTextModelContentProviders.js';
35
import { createSnapshot as createNotebookSnapshot, restoreSnapshot as restoreNotebookSnapshot } from './notebook/chatEditingModifiedNotebookSnapshot.js';
36
37
const START_REQUEST_EPOCH = '$$start';
38
const STOP_ID_EPOCH_PREFIX = '__epoch_';
39
40
type IReconstructedFileStateWithNotebook = IReconstructedFileNotExistsState | (Mutable<IReconstructedFileExistsState> & { notebook?: INotebookTextModel });
41
42
/**
43
* A filesystem delegate used by the checkpointing timeline such that
44
* navigating in the timeline tracks the changes as agent-initiated.
45
*/
46
export interface IChatEditingTimelineFsDelegate {
47
/** Creates a file with initial content. */
48
createFile: (uri: URI, initialContent: string) => Promise<unknown>;
49
/** Delete a URI */
50
deleteFile: (uri: URI) => Promise<void>;
51
/** Rename a URI, retaining contents */
52
renameFile: (fromUri: URI, toUri: URI) => Promise<void>;
53
/** Set a URI contents, should create it if it does not already exist */
54
setContents(uri: URI, content: string, telemetryInfo: IModifiedEntryTelemetryInfo): Promise<void>;
55
}
56
57
/**
58
* Implementation of the checkpoint-based timeline system.
59
*
60
* Invariants:
61
* - There is at most one checkpoint or operation per epoch
62
* - _checkpoints and _operations are always sorted in ascending order by epoch
63
* - _currentEpoch being equal to the epoch of an operation means that
64
* operation is _not_ currently applied
65
*/
66
export class ChatEditingCheckpointTimelineImpl implements IChatEditingCheckpointTimeline {
67
68
private _epochCounter = 0;
69
private readonly _checkpoints = observableValue<readonly ICheckpoint[]>(this, []);
70
private readonly _currentEpoch = observableValue<number>(this, 0);
71
private readonly _operations = observableValueOpts<FileOperation[]>({ equalsFn: () => false }, []); // mutable
72
private readonly _fileBaselines = new Map<string, IFileBaseline>(); // key: `${uri}::${requestId}`
73
private readonly _refCountedDiffs = new Map<string, IObservable<IEditSessionEntryDiff | undefined>>();
74
75
/** Gets the checkpoint, if any, we can 'undo' to. */
76
private readonly _willUndoToCheckpoint = derived(reader => {
77
const currentEpoch = this._currentEpoch.read(reader);
78
const checkpoints = this._checkpoints.read(reader);
79
if (checkpoints.length < 2 || currentEpoch <= checkpoints[1].epoch) {
80
return undefined;
81
}
82
83
const operations = this._operations.read(reader);
84
85
// Undo either to right before the current request...
86
const currentCheckpointIdx = findLastIdx(checkpoints, cp => cp.epoch < currentEpoch);
87
const startOfRequest = currentCheckpointIdx === -1 ? undefined : findLast(checkpoints, cp => cp.undoStopId === undefined, currentCheckpointIdx);
88
89
// Or to the checkpoint before the last operation in this request
90
const previousOperation = findLast(operations, op => op.epoch < currentEpoch);
91
const previousCheckpoint = previousOperation && findLast(checkpoints, cp => cp.epoch < previousOperation.epoch);
92
93
if (!startOfRequest) {
94
return previousCheckpoint;
95
}
96
if (!previousCheckpoint) {
97
return startOfRequest;
98
}
99
100
// Special case: if we're undoing the first edit operation, undo the entire request
101
if (!operations.some(op => op.epoch > startOfRequest.epoch && op.epoch < previousCheckpoint!.epoch)) {
102
return startOfRequest;
103
}
104
105
return previousCheckpoint.epoch > startOfRequest.epoch ? previousCheckpoint : startOfRequest;
106
});
107
108
public readonly canUndo: IObservable<boolean> = this._willUndoToCheckpoint.map(cp => !!cp);
109
110
111
/**
112
* Gets the epoch we'll redo this. Unlike undo this doesn't only use checkpoints
113
* because we could potentially redo to a 'tip' operation that's not checkpointed yet.
114
*/
115
private readonly _willRedoToEpoch = derived(reader => {
116
const currentEpoch = this._currentEpoch.read(reader);
117
const operations = this._operations.read(reader);
118
const checkpoints = this._checkpoints.read(reader);
119
const maxEncounteredEpoch = Math.max(operations.at(-1)?.epoch || 0, checkpoints.at(-1)?.epoch || 0);
120
if (currentEpoch > maxEncounteredEpoch) {
121
return undefined;
122
}
123
124
// Find the next edit operation that would be applied...
125
const nextOperation = operations.find(op => op.epoch >= currentEpoch);
126
const nextCheckpoint = nextOperation && checkpoints.find(op => op.epoch > nextOperation.epoch);
127
128
// And figure out where we're going if we're navigating across request
129
// 1. If there is no next request or if the next target checkpoint is in
130
// the next request, navigate there.
131
// 2. Otherwise, navigate to the end of the next request.
132
const currentCheckpoint = findLast(checkpoints, cp => cp.epoch < currentEpoch);
133
if (currentCheckpoint && nextOperation && currentCheckpoint.requestId !== nextOperation.requestId) {
134
const startOfNextRequestIdx = findLastIdx(checkpoints, (cp, i) =>
135
cp.undoStopId === undefined && (checkpoints[i - 1]?.requestId === currentCheckpoint.requestId));
136
const startOfNextRequest = startOfNextRequestIdx === -1 ? undefined : checkpoints[startOfNextRequestIdx];
137
138
if (startOfNextRequest && nextOperation.requestId !== startOfNextRequest.requestId) {
139
const requestAfterTheNext = findFirst(checkpoints, op => op.undoStopId === undefined, startOfNextRequestIdx + 1);
140
if (requestAfterTheNext) {
141
return requestAfterTheNext.epoch;
142
}
143
}
144
}
145
146
return Math.min(
147
nextCheckpoint?.epoch || Infinity,
148
(maxEncounteredEpoch + 1),
149
);
150
});
151
152
public readonly canRedo: IObservable<boolean> = this._willRedoToEpoch.map(e => !!e);
153
154
public readonly requestDisablement: IObservable<IChatRequestDisablement[]> = derivedOpts(
155
{ equalsFn: (a, b) => arraysEqual(a, b, objectsEqual) },
156
reader => {
157
const currentEpoch = this._currentEpoch.read(reader);
158
const operations = this._operations.read(reader);
159
const checkpoints = this._checkpoints.read(reader);
160
161
const maxEncounteredEpoch = Math.max(operations.at(-1)?.epoch || 0, checkpoints.at(-1)?.epoch || 0);
162
if (currentEpoch > maxEncounteredEpoch) {
163
return []; // common case -- nothing undone
164
}
165
166
const lastAppliedOperation = findLast(operations, op => op.epoch < currentEpoch)?.epoch || 0;
167
const lastAppliedRequest = findLast(checkpoints, cp => cp.epoch < currentEpoch && cp.undoStopId === undefined)?.epoch || 0;
168
const stopDisablingAtEpoch = Math.max(lastAppliedOperation, lastAppliedRequest);
169
170
const disablement = new Map<string, string | undefined>();
171
172
// Go through the checkpoints and disable any until the one that contains the last applied operation.
173
// Subtle: the request will first make a checkpoint with an 'undefined' undo
174
// stop, and in this loop we'll "automatically" disable the entire request when
175
// we reach that checkpoint.
176
for (let i = checkpoints.length - 1; i >= 0; i--) {
177
const { undoStopId, requestId, epoch } = checkpoints[i];
178
if (epoch <= stopDisablingAtEpoch) {
179
break;
180
}
181
182
if (requestId) {
183
disablement.set(requestId, undoStopId);
184
}
185
}
186
187
return [...disablement].map(([requestId, afterUndoStop]): IChatRequestDisablement => ({ requestId, afterUndoStop }));
188
});
189
190
constructor(
191
private readonly chatSessionResource: URI,
192
private readonly _delegate: IChatEditingTimelineFsDelegate,
193
@INotebookEditorModelResolverService private readonly _notebookEditorModelResolverService: INotebookEditorModelResolverService,
194
@INotebookService private readonly _notebookService: INotebookService,
195
@IInstantiationService private readonly _instantiationService: IInstantiationService,
196
@IModelService private readonly _modelService: IModelService,
197
@ITextModelService private readonly _textModelService: ITextModelService,
198
@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,
199
@IConfigurationService private readonly _configurationService: IConfigurationService
200
) {
201
this.createCheckpoint(undefined, undefined, 'Initial State', 'Starting point before any edits');
202
}
203
204
public createCheckpoint(requestId: string | undefined, undoStopId: string | undefined, label: string, description?: string): string {
205
const existingCheckpoints = this._checkpoints.get();
206
const existing = existingCheckpoints.find(c => c.undoStopId === undoStopId && c.requestId === requestId);
207
if (existing) {
208
return existing.checkpointId;
209
}
210
211
const { checkpoints, operations } = this._getVisibleOperationsAndCheckpoints();
212
const checkpointId = generateUuid();
213
const epoch = this.incrementEpoch();
214
215
checkpoints.push({
216
checkpointId,
217
requestId,
218
undoStopId,
219
epoch,
220
label,
221
description
222
});
223
224
transaction(tx => {
225
this._checkpoints.set(checkpoints, tx);
226
this._operations.set(operations, tx);
227
this._currentEpoch.set(epoch + 1, tx);
228
});
229
230
return checkpointId;
231
}
232
233
public async undoToLastCheckpoint(): Promise<void> {
234
const checkpoint = this._willUndoToCheckpoint.get();
235
if (checkpoint) {
236
await this.navigateToCheckpoint(checkpoint.checkpointId);
237
}
238
}
239
240
public async redoToNextCheckpoint(): Promise<void> {
241
const targetEpoch = this._willRedoToEpoch.get();
242
if (targetEpoch) {
243
await this._navigateToEpoch(targetEpoch);
244
}
245
}
246
247
public navigateToCheckpoint(checkpointId: string): Promise<void> {
248
const targetCheckpoint = this._getCheckpoint(checkpointId);
249
if (!targetCheckpoint) {
250
throw new Error(`Checkpoint ${checkpointId} not found`);
251
}
252
253
if (targetCheckpoint.undoStopId === undefined) {
254
// If we're navigating to the start of a request, we want to restore the file
255
// to whatever baseline we captured, _not_ the result state from the prior request
256
// because there may have been user changes in the meantime. But we still want
257
// to set the epoch marking that checkpoint as having been undone (the second
258
// arg below) so that disablement works and so it's discarded if appropriate later.
259
return this._navigateToEpoch(targetCheckpoint.epoch + 1, targetCheckpoint.epoch);
260
} else {
261
return this._navigateToEpoch(targetCheckpoint.epoch + 1);
262
}
263
264
}
265
266
public getContentURIAtStop(requestId: string, fileURI: URI, stopId: string | undefined): URI {
267
return ChatEditingSnapshotTextModelContentProvider.getSnapshotFileURI(this.chatSessionResource, requestId, stopId, fileURI.path);
268
}
269
270
private async _navigateToEpoch(restoreToEpoch: number, navigateToEpoch = restoreToEpoch): Promise<void> {
271
const currentEpoch = this._currentEpoch.get();
272
if (currentEpoch !== restoreToEpoch) {
273
const urisToRestore = await this._applyFileSystemOperations(currentEpoch, restoreToEpoch);
274
275
// Reconstruct content for files affected by operations in the range
276
await this._reconstructAllFileContents(restoreToEpoch, urisToRestore);
277
}
278
279
// Update current epoch
280
this._currentEpoch.set(navigateToEpoch, undefined);
281
}
282
283
private _getCheckpoint(checkpointId: string): ICheckpoint | undefined {
284
return this._checkpoints.get().find(c => c.checkpointId === checkpointId);
285
}
286
287
public incrementEpoch() {
288
return this._epochCounter++;
289
}
290
291
public recordFileOperation(operation: FileOperation): void {
292
const { currentEpoch, checkpoints, operations } = this._getVisibleOperationsAndCheckpoints();
293
if (operation.epoch < currentEpoch) {
294
throw new Error(`Cannot record operation at epoch ${operation.epoch} when current epoch is ${currentEpoch}`);
295
}
296
297
operations.push(operation);
298
transaction(tx => {
299
this._checkpoints.set(checkpoints, tx);
300
this._operations.set(operations, tx);
301
this._currentEpoch.set(operation.epoch + 1, tx);
302
});
303
}
304
305
private _getVisibleOperationsAndCheckpoints() {
306
const currentEpoch = this._currentEpoch.get();
307
const checkpoints = this._checkpoints.get();
308
const operations = this._operations.get();
309
310
return {
311
currentEpoch,
312
checkpoints: checkpoints.filter(c => c.epoch < currentEpoch),
313
operations: operations.filter(op => op.epoch < currentEpoch)
314
};
315
}
316
317
public recordFileBaseline(baseline: IFileBaseline): void {
318
const key = this._getBaselineKey(baseline.uri, baseline.requestId);
319
this._fileBaselines.set(key, baseline);
320
}
321
322
private _getFileBaseline(uri: URI, requestId: string): IFileBaseline | undefined {
323
const key = this._getBaselineKey(uri, requestId);
324
return this._fileBaselines.get(key);
325
}
326
327
public hasFileBaseline(uri: URI, requestId: string): boolean {
328
const key = this._getBaselineKey(uri, requestId);
329
return this._fileBaselines.has(key) || this._operations.get().some(op =>
330
op.type === FileOperationType.Create && op.requestId === requestId && isEqual(uri, op.uri));
331
}
332
333
public async getContentAtStop(requestId: string, contentURI: URI, stopId: string | undefined) {
334
let toEpoch: number | undefined;
335
if (stopId?.startsWith(STOP_ID_EPOCH_PREFIX)) {
336
toEpoch = Number(stopId.slice(STOP_ID_EPOCH_PREFIX.length));
337
} else {
338
toEpoch = this._checkpoints.get().find(c => c.requestId === requestId && c.undoStopId === stopId)?.epoch;
339
}
340
341
// The content URI doesn't preserve the original scheme or authority. Look through
342
// to find the operation that touched that path to get its actual URI
343
const fileURI = this._getTimelineCanonicalUriForPath(contentURI);
344
345
if (!toEpoch || !fileURI) {
346
return '';
347
}
348
349
const baseline = await this._findBestBaselineForFile(fileURI, toEpoch, requestId);
350
if (!baseline) {
351
return '';
352
}
353
354
const operations = this._getFileOperationsInRange(fileURI, baseline.epoch, toEpoch);
355
const replayed = await this._replayOperations(baseline, operations);
356
return replayed.exists ? replayed.content : undefined;
357
}
358
359
private _getTimelineCanonicalUriForPath(contentURI: URI) {
360
for (const it of [this._fileBaselines.values(), this._operations.get()]) {
361
for (const thing of it) {
362
if (thing.uri.path === contentURI.path) {
363
return thing.uri;
364
}
365
}
366
}
367
368
return undefined;
369
}
370
371
/**
372
* Creates a callback that is invoked when data at the stop changes. This
373
* will not fire initially and may be debounced internally.
374
*/
375
public onDidChangeContentsAtStop(requestId: string, contentURI: URI, stopId: string | undefined, callback: (data: string) => void): IDisposable {
376
// The only case where we have data that updates is if we have an epoch pointer that's
377
// after our know epochs (e.g. pointing to the end file state after all operations).
378
// If this isn't the case, abort.
379
if (!stopId || !stopId.startsWith(STOP_ID_EPOCH_PREFIX)) {
380
return Disposable.None;
381
}
382
383
const target = Number(stopId.slice(STOP_ID_EPOCH_PREFIX.length));
384
if (target <= this._epochCounter) {
385
return Disposable.None; // already finalized
386
}
387
388
const store = new DisposableStore();
389
const scheduler = store.add(new ThrottledDelayer(500));
390
391
store.add(Event.fromObservableLight(this._operations)(() => {
392
scheduler.trigger(async () => {
393
if (this._operations.get().at(-1)?.epoch! >= target) {
394
store.dispose();
395
}
396
397
const content = await this.getContentAtStop(requestId, contentURI, stopId);
398
if (content !== undefined) {
399
callback(content);
400
}
401
});
402
}));
403
404
return store;
405
}
406
407
private _getCheckpointBeforeEpoch(epoch: number, reader?: IReader) {
408
return findLast(this._checkpoints.read(reader), c => c.epoch <= epoch);
409
}
410
411
private async _reconstructFileState(uri: URI, targetEpoch: number): Promise<IReconstructedFileState> {
412
const targetCheckpoint = this._getCheckpointBeforeEpoch(targetEpoch);
413
if (!targetCheckpoint) {
414
throw new Error(`Checkpoint for epoch ${targetEpoch} not found`);
415
}
416
417
// Find the most appropriate baseline for this file
418
const baseline = await this._findBestBaselineForFile(uri, targetEpoch, targetCheckpoint.requestId || '');
419
if (!baseline) {
420
// File doesn't exist at this checkpoint
421
return {
422
exists: false,
423
uri,
424
};
425
}
426
427
// Get operations that affect this file from baseline to target checkpoint
428
const operations = this._getFileOperationsInRange(uri, baseline.epoch, targetEpoch);
429
430
// Replay operations to reconstruct state
431
return this._replayOperations(baseline, operations);
432
}
433
434
public getStateForPersistence(): IChatEditingTimelineState {
435
return {
436
checkpoints: this._checkpoints.get(),
437
currentEpoch: this._currentEpoch.get(),
438
fileBaselines: [...this._fileBaselines],
439
operations: this._operations.get(),
440
epochCounter: this._epochCounter,
441
};
442
}
443
444
public restoreFromState(state: IChatEditingTimelineState, tx: ITransaction): void {
445
this._checkpoints.set(state.checkpoints, tx);
446
this._currentEpoch.set(state.currentEpoch, tx);
447
this._operations.set(state.operations.slice(), tx);
448
this._epochCounter = state.epochCounter;
449
450
this._fileBaselines.clear();
451
for (const [key, baseline] of state.fileBaselines) {
452
this._fileBaselines.set(key, baseline);
453
}
454
}
455
456
public getCheckpointIdForRequest(requestId: string, undoStopId?: string): string | undefined {
457
const checkpoints = this._checkpoints.get();
458
return checkpoints.find(c => c.requestId === requestId && c.undoStopId === undoStopId)?.checkpointId;
459
}
460
461
private async _reconstructAllFileContents(targetEpoch: number, filesToReconstruct: ResourceSet): Promise<void> {
462
await Promise.all(Array.from(filesToReconstruct).map(async uri => {
463
const reconstructedState = await this._reconstructFileState(uri, targetEpoch);
464
if (reconstructedState.exists) {
465
await this._delegate.setContents(reconstructedState.uri, reconstructedState.content, reconstructedState.telemetryInfo);
466
}
467
}));
468
}
469
470
private _getBaselineKey(uri: URI, requestId: string): string {
471
return `${uri.toString()}::${requestId}`;
472
}
473
474
private async _findBestBaselineForFile(uri: URI, epoch: number, requestId: string): Promise<IFileBaseline | undefined> {
475
// First, iterate backwards through operations before the target checkpoint
476
// to see if the file was created/re-created more recently than any baseline
477
478
let currentRequestId = requestId;
479
const operations = this._operations.get();
480
for (let i = operations.length - 1; i >= 0; i--) {
481
const operation = operations[i];
482
if (operation.epoch > epoch) {
483
continue;
484
}
485
486
// If the file was just created, use that as its updated baseline
487
if (operation.type === FileOperationType.Create && isEqual(operation.uri, uri)) {
488
return {
489
uri: operation.uri,
490
requestId: operation.requestId,
491
content: operation.initialContent,
492
epoch: operation.epoch,
493
telemetryInfo: operation.telemetryInfo,
494
};
495
}
496
497
// If the file was renamed to this URI, use its old contents as the baseline
498
if (operation.type === FileOperationType.Rename && isEqual(operation.newUri, uri)) {
499
const prev = await this._findBestBaselineForFile(operation.oldUri, operation.epoch, operation.requestId);
500
if (!prev) {
501
return undefined;
502
}
503
504
505
const operations = this._getFileOperationsInRange(operation.oldUri, prev.epoch, operation.epoch);
506
const replayed = await this._replayOperations(prev, operations);
507
return {
508
uri: uri,
509
epoch: operation.epoch,
510
content: replayed.exists ? replayed.content : '',
511
requestId: operation.requestId,
512
telemetryInfo: prev.telemetryInfo,
513
notebookViewType: replayed.exists ? replayed.notebookViewType : undefined,
514
};
515
}
516
517
// When the request ID changes, check if we have a baseline for the current request
518
if (currentRequestId && operation.requestId !== currentRequestId) {
519
const baseline = this._getFileBaseline(uri, currentRequestId);
520
if (baseline) {
521
return baseline;
522
}
523
}
524
525
currentRequestId = operation.requestId;
526
}
527
528
// Check the final request ID for a baseline
529
return this._getFileBaseline(uri, currentRequestId);
530
}
531
532
private _getFileOperationsInRange(uri: URI, fromEpoch: number, toEpoch: number): readonly FileOperation[] {
533
return this._operations.get().filter(op => {
534
const cellUri = CellUri.parse(op.uri);
535
return op.epoch >= fromEpoch &&
536
op.epoch < toEpoch &&
537
(isEqual(op.uri, uri) || (cellUri && isEqual(cellUri.notebook, uri)));
538
}).sort((a, b) => a.epoch - b.epoch);
539
}
540
541
private async _replayOperations(baseline: IFileBaseline, operations: readonly FileOperation[]): Promise<IReconstructedFileState> {
542
let currentState: IReconstructedFileStateWithNotebook = {
543
exists: true,
544
content: baseline.content,
545
uri: baseline.uri,
546
telemetryInfo: baseline.telemetryInfo,
547
};
548
549
if (baseline.notebookViewType) {
550
currentState.notebook = await this._notebookEditorModelResolverService.createUntitledNotebookTextModel(baseline.notebookViewType);
551
if (baseline.content) {
552
restoreNotebookSnapshot(currentState.notebook, baseline.content);
553
}
554
}
555
556
for (const operation of operations) {
557
currentState = await this._applyOperationToState(currentState, operation, baseline.telemetryInfo);
558
}
559
560
if (currentState.exists && currentState.notebook) {
561
const info = await this._notebookService.withNotebookDataProvider(currentState.notebook.viewType);
562
currentState.content = createNotebookSnapshot(currentState.notebook, info.serializer.options, this._configurationService);
563
currentState.notebook.dispose();
564
}
565
566
return currentState;
567
}
568
569
private async _applyOperationToState(state: IReconstructedFileStateWithNotebook, operation: FileOperation, telemetryInfo: IModifiedEntryTelemetryInfo): Promise<IReconstructedFileStateWithNotebook> {
570
switch (operation.type) {
571
case FileOperationType.Create: {
572
if (state.exists && state.notebook) {
573
state.notebook.dispose();
574
}
575
576
let notebook: INotebookTextModel | undefined;
577
if (operation.notebookViewType) {
578
notebook = await this._notebookEditorModelResolverService.createUntitledNotebookTextModel(operation.notebookViewType);
579
if (operation.initialContent) {
580
restoreNotebookSnapshot(notebook, operation.initialContent);
581
}
582
}
583
584
return {
585
exists: true,
586
content: operation.initialContent,
587
uri: operation.uri,
588
telemetryInfo,
589
notebookViewType: operation.notebookViewType,
590
notebook,
591
};
592
}
593
594
case FileOperationType.Delete:
595
if (state.exists && state.notebook) {
596
state.notebook.dispose();
597
}
598
599
return {
600
exists: false,
601
uri: operation.uri
602
};
603
604
case FileOperationType.Rename:
605
return {
606
...state,
607
uri: operation.newUri
608
};
609
610
case FileOperationType.TextEdit: {
611
if (!state.exists) {
612
throw new Error('Cannot apply text edits to non-existent file');
613
}
614
615
const nbCell = operation.cellIndex !== undefined && state.notebook?.cells.at(operation.cellIndex);
616
if (nbCell) {
617
const newContent = this._applyTextEditsToContent(nbCell.getValue(), operation.edits);
618
state.notebook!.applyEdits([{
619
editType: CellEditType.Replace,
620
index: operation.cellIndex,
621
count: 1,
622
cells: [{ cellKind: nbCell.cellKind, language: nbCell.language, mime: nbCell.language, source: newContent, outputs: nbCell.outputs }]
623
}], true, undefined, () => undefined, undefined);
624
return state;
625
}
626
627
// Apply text edits using a temporary text model
628
return {
629
...state,
630
content: this._applyTextEditsToContent(state.content, operation.edits)
631
};
632
}
633
case FileOperationType.NotebookEdit:
634
if (!state.exists) {
635
throw new Error('Cannot apply notebook edits to non-existent file');
636
}
637
if (!state.notebook) {
638
throw new Error('Cannot apply notebook edits to non-notebook file');
639
}
640
641
state.notebook.applyEdits(operation.cellEdits.slice(), true, undefined, () => undefined, undefined);
642
return state;
643
644
default:
645
assertNever(operation);
646
}
647
}
648
649
private async _applyFileSystemOperations(fromEpoch: number, toEpoch: number): Promise<ResourceSet> {
650
const isMovingForward = toEpoch > fromEpoch;
651
const operations = this._operations.get().filter(op => {
652
if (isMovingForward) {
653
return op.epoch >= fromEpoch && op.epoch < toEpoch;
654
} else {
655
return op.epoch < fromEpoch && op.epoch >= toEpoch;
656
}
657
}).sort((a, b) => isMovingForward ? a.epoch - b.epoch : b.epoch - a.epoch);
658
659
// Apply file system operations in the correct direction
660
const urisToRestore = new ResourceSet();
661
for (const operation of operations) {
662
await this._applyFileSystemOperation(operation, isMovingForward, urisToRestore);
663
}
664
665
return urisToRestore;
666
}
667
668
private async _applyFileSystemOperation(operation: FileOperation, isMovingForward: boolean, urisToRestore: ResourceSet): Promise<void> {
669
switch (operation.type) {
670
case FileOperationType.Create:
671
if (isMovingForward) {
672
await this._delegate.createFile(operation.uri, operation.initialContent);
673
urisToRestore.add(operation.uri);
674
} else {
675
await this._delegate.deleteFile(operation.uri);
676
urisToRestore.delete(operation.uri);
677
}
678
break;
679
680
case FileOperationType.Delete:
681
if (isMovingForward) {
682
await this._delegate.deleteFile(operation.uri);
683
urisToRestore.delete(operation.uri);
684
} else {
685
await this._delegate.createFile(operation.uri, operation.finalContent);
686
urisToRestore.add(operation.uri);
687
}
688
break;
689
690
case FileOperationType.Rename:
691
if (isMovingForward) {
692
await this._delegate.renameFile(operation.oldUri, operation.newUri);
693
urisToRestore.delete(operation.oldUri);
694
urisToRestore.add(operation.newUri);
695
} else {
696
await this._delegate.renameFile(operation.newUri, operation.oldUri);
697
urisToRestore.delete(operation.newUri);
698
urisToRestore.add(operation.oldUri);
699
}
700
break;
701
702
// Text and notebook edits don't affect file system structure
703
case FileOperationType.TextEdit:
704
case FileOperationType.NotebookEdit:
705
urisToRestore.add(CellUri.parse(operation.uri)?.notebook ?? operation.uri);
706
break;
707
708
default:
709
assertNever(operation);
710
}
711
}
712
713
private _applyTextEditsToContent(content: string, edits: readonly TextEdit[]): string {
714
// Use the example pattern provided by the user
715
const makeModel = (uri: URI, contents: string) => this._instantiationService.createInstance(TextModel, contents, '', this._modelService.getCreationOptions('', uri, true), uri);
716
717
// Create a temporary URI for the model
718
const tempUri = URI.from({ scheme: 'temp', path: `/temp-${Date.now()}.txt` });
719
const model = makeModel(tempUri, content);
720
721
try {
722
// Apply edits
723
model.applyEdits(edits.map(edit => ({
724
range: {
725
startLineNumber: edit.range.startLineNumber,
726
startColumn: edit.range.startColumn,
727
endLineNumber: edit.range.endLineNumber,
728
endColumn: edit.range.endColumn
729
},
730
text: edit.text
731
})));
732
733
return model.getValue();
734
} finally {
735
model.dispose();
736
}
737
}
738
739
public getEntryDiffBetweenStops(uri: URI, requestId: string | undefined, stopId: string | undefined): IObservable<IEditSessionEntryDiff | undefined> {
740
const epochs = derivedOpts<{ start: ICheckpoint; end: ICheckpoint | undefined }>({ equalsFn: (a, b) => a.start === b.start && a.end === b.end }, reader => {
741
const checkpoints = this._checkpoints.read(reader);
742
const startIndex = checkpoints.findIndex(c => c.requestId === requestId && c.undoStopId === stopId);
743
return { start: checkpoints[startIndex], end: checkpoints[startIndex + 1] };
744
});
745
746
return this._getEntryDiffBetweenEpochs(uri, `s\0${requestId}\0${stopId}`, epochs);
747
}
748
749
/** Gets the epoch bounds of the request. If stopRequestId is undefined, gets ONLY the single request's bounds */
750
private _getRequestEpochBounds(startRequestId: string, stopRequestId?: string): IObservable<{ start: ICheckpoint; end: ICheckpoint | undefined }> {
751
return derivedOpts<{ start: ICheckpoint; end: ICheckpoint | undefined }>({ equalsFn: (a, b) => a.start === b.start && a.end === b.end }, reader => {
752
const checkpoints = this._checkpoints.read(reader);
753
const startIndex = checkpoints.findIndex(c => c.requestId === startRequestId);
754
const start = startIndex === -1 ? checkpoints[0] : checkpoints[startIndex];
755
756
let end: ICheckpoint | undefined;
757
if (stopRequestId === undefined) {
758
end = findFirst(checkpoints, c => c.requestId !== startRequestId, startIndex + 1);
759
} else {
760
end = checkpoints.find(c => c.requestId === stopRequestId)
761
|| findFirst(checkpoints, c => c.requestId !== startRequestId, startIndex + 1)
762
|| checkpoints[checkpoints.length - 1];
763
}
764
765
return { start, end };
766
});
767
}
768
769
public getEntryDiffBetweenRequests(uri: URI, startRequestId: string, stopRequestId: string): IObservable<IEditSessionEntryDiff | undefined> {
770
return this._getEntryDiffBetweenEpochs(uri, `r\0${startRequestId}\0${stopRequestId}`, this._getRequestEpochBounds(startRequestId, stopRequestId));
771
}
772
773
private _getEntryDiffBetweenEpochs(uri: URI, cacheKey: string, epochs: IObservable<{ start: ICheckpoint | undefined; end: ICheckpoint | undefined }>): IObservable<IEditSessionEntryDiff | undefined> {
774
const key = `${uri.toString()}\0${cacheKey}`;
775
let obs = this._refCountedDiffs.get(key);
776
777
if (!obs) {
778
obs = this._getEntryDiffBetweenEpochsInner(
779
uri,
780
epochs,
781
() => this._refCountedDiffs.delete(key),
782
);
783
this._refCountedDiffs.set(key, obs);
784
}
785
786
return obs;
787
}
788
789
private _getEntryDiffBetweenEpochsInner(
790
uri: URI,
791
epochs: IObservable<{ start: ICheckpoint | undefined; end: ICheckpoint | undefined }>,
792
onLastObserverRemoved: () => void,
793
): IObservable<IEditSessionEntryDiff | undefined> {
794
type ModelRefsValue = { refs: { model: ITextModel; onChange: IObservable<void> }[]; isFinal: boolean; error?: unknown };
795
796
const modelRefsPromise = derived(this, (reader) => {
797
const { start, end } = epochs.read(reader);
798
if (!start) { return undefined; }
799
800
const store = reader.store.add(new DisposableStore());
801
const originalURI = this.getContentURIAtStop(start.requestId || START_REQUEST_EPOCH, uri, STOP_ID_EPOCH_PREFIX + start.epoch);
802
const modifiedURI = this.getContentURIAtStop(end?.requestId || start.requestId || START_REQUEST_EPOCH, uri, STOP_ID_EPOCH_PREFIX + (end?.epoch || Number.MAX_SAFE_INTEGER));
803
804
const promise: Promise<ModelRefsValue> = Promise.all([
805
this._textModelService.createModelReference(originalURI),
806
this._textModelService.createModelReference(modifiedURI),
807
]).then(refs => {
808
if (store.isDisposed) {
809
refs.forEach(r => r.dispose());
810
} else {
811
refs.forEach(r => store.add(r));
812
}
813
814
return {
815
refs: refs.map(r => ({
816
model: r.object.textEditorModel,
817
onChange: observableSignalFromEvent(this, r.object.textEditorModel.onDidChangeContent.bind(r.object.textEditorModel)),
818
})),
819
isFinal: !!end,
820
};
821
}).catch((error): ModelRefsValue => {
822
return { refs: [], isFinal: true, error };
823
});
824
825
return {
826
originalURI,
827
modifiedURI,
828
promise: new ObservablePromise(promise),
829
};
830
});
831
832
const diff = derived(reader => {
833
const modelsData = modelRefsPromise.read(reader);
834
if (!modelsData) {
835
return;
836
}
837
838
const { originalURI, modifiedURI, promise } = modelsData;
839
const promiseData = promise?.promiseResult.read(reader);
840
if (!promiseData?.data) {
841
return { originalURI, modifiedURI, promise: undefined };
842
}
843
844
const { refs, isFinal, error } = promiseData.data;
845
if (error) {
846
return { originalURI, modifiedURI, promise: new ObservablePromise(Promise.resolve(emptySessionEntryDiff(originalURI, modifiedURI))) };
847
}
848
849
refs.forEach(m => m.onChange.read(reader)); // re-read when contents change
850
851
return { originalURI, modifiedURI, promise: new ObservablePromise(this._computeDiff(originalURI, modifiedURI, !!isFinal)) };
852
});
853
854
return derivedOpts({ onLastObserverRemoved }, reader => {
855
const result = diff.read(reader);
856
if (!result) {
857
return undefined;
858
}
859
860
const promised = result.promise?.promiseResult.read(reader);
861
if (promised?.data) {
862
return promised.data;
863
}
864
865
if (promised?.error) {
866
return emptySessionEntryDiff(result.originalURI, result.modifiedURI);
867
}
868
869
return { ...emptySessionEntryDiff(result.originalURI, result.modifiedURI), isBusy: true };
870
});
871
}
872
873
private _computeDiff(originalUri: URI, modifiedUri: URI, isFinal: boolean): Promise<IEditSessionEntryDiff> {
874
return this._editorWorkerService.computeDiff(
875
originalUri,
876
modifiedUri,
877
{ ignoreTrimWhitespace: false, computeMoves: false, maxComputationTimeMs: 3000 },
878
'advanced'
879
).then((diff): IEditSessionEntryDiff => {
880
const entryDiff: IEditSessionEntryDiff = {
881
originalURI: originalUri,
882
modifiedURI: modifiedUri,
883
identical: !!diff?.identical,
884
isFinal,
885
quitEarly: !diff || diff.quitEarly,
886
added: 0,
887
removed: 0,
888
isBusy: false,
889
};
890
if (diff) {
891
for (const change of diff.changes) {
892
entryDiff.removed += change.original.endLineNumberExclusive - change.original.startLineNumber;
893
entryDiff.added += change.modified.endLineNumberExclusive - change.modified.startLineNumber;
894
}
895
}
896
return entryDiff;
897
});
898
}
899
900
public hasEditsInRequest(requestId: string, reader?: IReader): boolean {
901
for (const value of this._fileBaselines.values()) {
902
if (value.requestId === requestId) {
903
return true;
904
}
905
}
906
907
for (const operation of this._operations.read(reader)) {
908
if (operation.requestId === requestId) {
909
return true;
910
}
911
}
912
913
return false;
914
}
915
916
public getDiffsForFilesInRequest(requestId: string): IObservable<readonly IEditSessionEntryDiff[]> {
917
const boundsObservable = this._getRequestEpochBounds(requestId);
918
const startEpochs = derivedOpts<ResourceMap<number>>({ equalsFn: mapsStrictEqualIgnoreOrder }, reader => {
919
const uris = new ResourceMap<number>();
920
for (const value of this._fileBaselines.values()) {
921
if (value.requestId === requestId) {
922
uris.set(value.uri, value.epoch);
923
}
924
}
925
926
const bounds = boundsObservable.read(reader);
927
for (const operation of this._operations.read(reader)) {
928
if (operation.epoch < bounds.start.epoch) {
929
continue;
930
}
931
if (bounds.end && operation.epoch >= bounds.end.epoch) {
932
break;
933
}
934
935
if (operation.type === FileOperationType.Create) {
936
uris.set(operation.uri, 0);
937
}
938
}
939
940
return uris;
941
});
942
943
944
return this._getDiffsForFilesAtEpochs(startEpochs, boundsObservable.map(b => b.end));
945
}
946
947
private _getDiffsForFilesAtEpochs(startEpochs: IObservable<ResourceMap<number>>, endCheckpointObs: IObservable<ICheckpoint | undefined>) {
948
// URIs are never removed from the set and we never adjust baselines backwards
949
// (history is immutable) so we can easily cache to avoid regenerating diffs when new files are added
950
const prevDiffs = new ResourceMap<IObservable<IEditSessionEntryDiff | undefined>>();
951
let prevEndCheckpoint: ICheckpoint | undefined = undefined;
952
953
const perFileDiffs = derived(this, reader => {
954
const checkpoints = this._checkpoints.read(reader);
955
const firstCheckpoint = checkpoints[0];
956
if (!firstCheckpoint) {
957
return [];
958
}
959
960
const endCheckpoint = endCheckpointObs.read(reader);
961
if (endCheckpoint !== prevEndCheckpoint) {
962
prevDiffs.clear();
963
prevEndCheckpoint = endCheckpoint;
964
}
965
966
const uris = startEpochs.read(reader);
967
const diffs: IObservable<IEditSessionEntryDiff | undefined>[] = [];
968
969
for (const [uri, epoch] of uris) {
970
const obs = prevDiffs.get(uri) ?? this._getEntryDiffBetweenEpochs(uri, `e\0${epoch}\0${endCheckpoint?.epoch}`,
971
constObservable({ start: checkpoints.findLast(cp => cp.epoch <= epoch) || firstCheckpoint, end: endCheckpoint }));
972
prevDiffs.set(uri, obs);
973
diffs.push(obs);
974
}
975
976
return diffs;
977
});
978
979
return perFileDiffs.map((diffs, reader) => {
980
return diffs.flatMap(d => d.read(reader)).filter(isDefined);
981
});
982
}
983
984
public getDiffsForFilesInSession(): IObservable<readonly IEditSessionEntryDiff[]> {
985
const startEpochs = derivedOpts<ResourceMap<number>>({ equalsFn: mapsStrictEqualIgnoreOrder }, reader => {
986
const uris = new ResourceMap<number>();
987
for (const baseline of this._fileBaselines.values()) {
988
uris.set(baseline.uri, Math.min(baseline.epoch, uris.get(baseline.uri) ?? Number.MAX_SAFE_INTEGER));
989
}
990
for (const operation of this._operations.read(reader)) {
991
if (operation.type === FileOperationType.Create) {
992
uris.set(operation.uri, 0);
993
}
994
}
995
996
return uris;
997
});
998
999
return this._getDiffsForFilesAtEpochs(startEpochs, constObservable(undefined));
1000
}
1001
1002
public getDiffForSession(): IObservable<IEditSessionDiffStats> {
1003
const fileDiffs = this.getDiffsForFilesInSession();
1004
return derived(reader => {
1005
const diffs = fileDiffs.read(reader);
1006
let added = 0;
1007
let removed = 0;
1008
for (const diff of diffs) {
1009
added += diff.added;
1010
removed += diff.removed;
1011
}
1012
return { added, removed };
1013
});
1014
}
1015
}
1016
1017