Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCheckpointTimelineImpl.ts
4780 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { equals as arraysEqual } from '../../../../../base/common/arrays.js';6import { findFirst, findLast, findLastIdx } from '../../../../../base/common/arraysFind.js';7import { assertNever } from '../../../../../base/common/assert.js';8import { ThrottledDelayer } from '../../../../../base/common/async.js';9import { Event } from '../../../../../base/common/event.js';10import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js';11import { mapsStrictEqualIgnoreOrder, ResourceMap, ResourceSet } from '../../../../../base/common/map.js';12import { equals as objectsEqual } from '../../../../../base/common/objects.js';13import { constObservable, derived, derivedOpts, IObservable, IReader, ITransaction, ObservablePromise, observableSignalFromEvent, observableValue, observableValueOpts, transaction } from '../../../../../base/common/observable.js';14import { isEqual } from '../../../../../base/common/resources.js';15import { isDefined, Mutable } from '../../../../../base/common/types.js';16import { URI } from '../../../../../base/common/uri.js';17import { generateUuid } from '../../../../../base/common/uuid.js';18import { TextEdit } from '../../../../../editor/common/languages.js';19import { ITextModel } from '../../../../../editor/common/model.js';20import { TextModel } from '../../../../../editor/common/model/textModel.js';21import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js';22import { IModelService } from '../../../../../editor/common/services/model.js';23import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';24import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';25import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';26import { CellEditType, CellUri, INotebookTextModel } from '../../../notebook/common/notebookCommon.js';27import { INotebookEditorModelResolverService } from '../../../notebook/common/notebookEditorModelResolverService.js';28import { INotebookService } from '../../../notebook/common/notebookService.js';29import { emptySessionEntryDiff, IEditSessionDiffStats, IEditSessionEntryDiff, IModifiedEntryTelemetryInfo } from '../../common/editing/chatEditingService.js';30import { IChatRequestDisablement } from '../../common/model/chatModel.js';31import { IChatEditingCheckpointTimeline } from './chatEditingCheckpointTimeline.js';32import { FileOperation, FileOperationType, IChatEditingTimelineState, ICheckpoint, IFileBaseline, IReconstructedFileExistsState, IReconstructedFileNotExistsState, IReconstructedFileState } from './chatEditingOperations.js';33import { ChatEditingSnapshotTextModelContentProvider } from './chatEditingTextModelContentProviders.js';34import { createSnapshot as createNotebookSnapshot, restoreSnapshot as restoreNotebookSnapshot } from './notebook/chatEditingModifiedNotebookSnapshot.js';3536const START_REQUEST_EPOCH = '$$start';37const STOP_ID_EPOCH_PREFIX = '__epoch_';3839type IReconstructedFileStateWithNotebook = IReconstructedFileNotExistsState | (Mutable<IReconstructedFileExistsState> & { notebook?: INotebookTextModel });4041/**42* A filesystem delegate used by the checkpointing timeline such that43* navigating in the timeline tracks the changes as agent-initiated.44*/45export interface IChatEditingTimelineFsDelegate {46/** Creates a file with initial content. */47createFile: (uri: URI, initialContent: string) => Promise<unknown>;48/** Delete a URI */49deleteFile: (uri: URI) => Promise<void>;50/** Rename a URI, retaining contents */51renameFile: (fromUri: URI, toUri: URI) => Promise<void>;52/** Set a URI contents, should create it if it does not already exist */53setContents(uri: URI, content: string, telemetryInfo: IModifiedEntryTelemetryInfo): Promise<void>;54}5556/**57* Implementation of the checkpoint-based timeline system.58*59* Invariants:60* - There is at most one checkpoint or operation per epoch61* - _checkpoints and _operations are always sorted in ascending order by epoch62* - _currentEpoch being equal to the epoch of an operation means that63* operation is _not_ currently applied64*/65export class ChatEditingCheckpointTimelineImpl implements IChatEditingCheckpointTimeline {6667private _epochCounter = 0;68private readonly _checkpoints = observableValue<readonly ICheckpoint[]>(this, []);69private readonly _currentEpoch = observableValue<number>(this, 0);70private readonly _operations = observableValueOpts<FileOperation[]>({ equalsFn: () => false }, []); // mutable71private readonly _fileBaselines = new Map<string, IFileBaseline>(); // key: `${uri}::${requestId}`72private readonly _refCountedDiffs = new Map<string, IObservable<IEditSessionEntryDiff | undefined>>();7374/** Gets the checkpoint, if any, we can 'undo' to. */75private readonly _willUndoToCheckpoint = derived(reader => {76const currentEpoch = this._currentEpoch.read(reader);77const checkpoints = this._checkpoints.read(reader);78if (checkpoints.length < 2 || currentEpoch <= checkpoints[1].epoch) {79return undefined;80}8182const operations = this._operations.read(reader);8384// Undo either to right before the current request...85const currentCheckpointIdx = findLastIdx(checkpoints, cp => cp.epoch < currentEpoch);86const startOfRequest = currentCheckpointIdx === -1 ? undefined : findLast(checkpoints, cp => cp.undoStopId === undefined, currentCheckpointIdx);8788// Or to the checkpoint before the last operation in this request89const previousOperation = findLast(operations, op => op.epoch < currentEpoch);90const previousCheckpoint = previousOperation && findLast(checkpoints, cp => cp.epoch < previousOperation.epoch);9192if (!startOfRequest) {93return previousCheckpoint;94}95if (!previousCheckpoint) {96return startOfRequest;97}9899// Special case: if we're undoing the first edit operation, undo the entire request100if (!operations.some(op => op.epoch > startOfRequest.epoch && op.epoch < previousCheckpoint!.epoch)) {101return startOfRequest;102}103104return previousCheckpoint.epoch > startOfRequest.epoch ? previousCheckpoint : startOfRequest;105});106107public readonly canUndo: IObservable<boolean> = this._willUndoToCheckpoint.map(cp => !!cp);108109110/**111* Gets the epoch we'll redo this. Unlike undo this doesn't only use checkpoints112* because we could potentially redo to a 'tip' operation that's not checkpointed yet.113*/114private readonly _willRedoToEpoch = derived(reader => {115const currentEpoch = this._currentEpoch.read(reader);116const operations = this._operations.read(reader);117const checkpoints = this._checkpoints.read(reader);118const maxEncounteredEpoch = Math.max(operations.at(-1)?.epoch || 0, checkpoints.at(-1)?.epoch || 0);119if (currentEpoch > maxEncounteredEpoch) {120return undefined;121}122123// Find the next edit operation that would be applied...124const nextOperation = operations.find(op => op.epoch >= currentEpoch);125const nextCheckpoint = nextOperation && checkpoints.find(op => op.epoch > nextOperation.epoch);126127// And figure out where we're going if we're navigating across request128// 1. If there is no next request or if the next target checkpoint is in129// the next request, navigate there.130// 2. Otherwise, navigate to the end of the next request.131const currentCheckpoint = findLast(checkpoints, cp => cp.epoch < currentEpoch);132if (currentCheckpoint && nextOperation && currentCheckpoint.requestId !== nextOperation.requestId) {133const startOfNextRequestIdx = findLastIdx(checkpoints, (cp, i) =>134cp.undoStopId === undefined && (checkpoints[i - 1]?.requestId === currentCheckpoint.requestId));135const startOfNextRequest = startOfNextRequestIdx === -1 ? undefined : checkpoints[startOfNextRequestIdx];136137if (startOfNextRequest && nextOperation.requestId !== startOfNextRequest.requestId) {138const requestAfterTheNext = findFirst(checkpoints, op => op.undoStopId === undefined, startOfNextRequestIdx + 1);139if (requestAfterTheNext) {140return requestAfterTheNext.epoch;141}142}143}144145return Math.min(146nextCheckpoint?.epoch || Infinity,147(maxEncounteredEpoch + 1),148);149});150151public readonly canRedo: IObservable<boolean> = this._willRedoToEpoch.map(e => !!e);152153public readonly requestDisablement: IObservable<IChatRequestDisablement[]> = derivedOpts(154{ equalsFn: (a, b) => arraysEqual(a, b, objectsEqual) },155reader => {156const currentEpoch = this._currentEpoch.read(reader);157const operations = this._operations.read(reader);158const checkpoints = this._checkpoints.read(reader);159160const maxEncounteredEpoch = Math.max(operations.at(-1)?.epoch || 0, checkpoints.at(-1)?.epoch || 0);161if (currentEpoch > maxEncounteredEpoch) {162return []; // common case -- nothing undone163}164165const lastAppliedOperation = findLast(operations, op => op.epoch < currentEpoch)?.epoch || 0;166const lastAppliedRequest = findLast(checkpoints, cp => cp.epoch < currentEpoch && cp.undoStopId === undefined)?.epoch || 0;167const stopDisablingAtEpoch = Math.max(lastAppliedOperation, lastAppliedRequest);168169const disablement = new Map<string, string | undefined>();170171// Go through the checkpoints and disable any until the one that contains the last applied operation.172// Subtle: the request will first make a checkpoint with an 'undefined' undo173// stop, and in this loop we'll "automatically" disable the entire request when174// we reach that checkpoint.175for (let i = checkpoints.length - 1; i >= 0; i--) {176const { undoStopId, requestId, epoch } = checkpoints[i];177if (epoch <= stopDisablingAtEpoch) {178break;179}180181if (requestId) {182disablement.set(requestId, undoStopId);183}184}185186return [...disablement].map(([requestId, afterUndoStop]): IChatRequestDisablement => ({ requestId, afterUndoStop }));187});188189constructor(190private readonly chatSessionResource: URI,191private readonly _delegate: IChatEditingTimelineFsDelegate,192@INotebookEditorModelResolverService private readonly _notebookEditorModelResolverService: INotebookEditorModelResolverService,193@INotebookService private readonly _notebookService: INotebookService,194@IInstantiationService private readonly _instantiationService: IInstantiationService,195@IModelService private readonly _modelService: IModelService,196@ITextModelService private readonly _textModelService: ITextModelService,197@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,198@IConfigurationService private readonly _configurationService: IConfigurationService199) {200this.createCheckpoint(undefined, undefined, 'Initial State', 'Starting point before any edits');201}202203public createCheckpoint(requestId: string | undefined, undoStopId: string | undefined, label: string, description?: string): string {204const existingCheckpoints = this._checkpoints.get();205const existing = existingCheckpoints.find(c => c.undoStopId === undoStopId && c.requestId === requestId);206if (existing) {207return existing.checkpointId;208}209210const { checkpoints, operations } = this._getVisibleOperationsAndCheckpoints();211const checkpointId = generateUuid();212const epoch = this.incrementEpoch();213214checkpoints.push({215checkpointId,216requestId,217undoStopId,218epoch,219label,220description221});222223transaction(tx => {224this._checkpoints.set(checkpoints, tx);225this._operations.set(operations, tx);226this._currentEpoch.set(epoch + 1, tx);227});228229return checkpointId;230}231232public async undoToLastCheckpoint(): Promise<void> {233const checkpoint = this._willUndoToCheckpoint.get();234if (checkpoint) {235await this.navigateToCheckpoint(checkpoint.checkpointId);236}237}238239public async redoToNextCheckpoint(): Promise<void> {240const targetEpoch = this._willRedoToEpoch.get();241if (targetEpoch) {242await this._navigateToEpoch(targetEpoch);243}244}245246public navigateToCheckpoint(checkpointId: string): Promise<void> {247const targetCheckpoint = this._getCheckpoint(checkpointId);248if (!targetCheckpoint) {249throw new Error(`Checkpoint ${checkpointId} not found`);250}251252if (targetCheckpoint.undoStopId === undefined) {253// If we're navigating to the start of a request, we want to restore the file254// to whatever baseline we captured, _not_ the result state from the prior request255// because there may have been user changes in the meantime. But we still want256// to set the epoch marking that checkpoint as having been undone (the second257// arg below) so that disablement works and so it's discarded if appropriate later.258return this._navigateToEpoch(targetCheckpoint.epoch + 1, targetCheckpoint.epoch);259} else {260return this._navigateToEpoch(targetCheckpoint.epoch + 1);261}262263}264265public getContentURIAtStop(requestId: string, fileURI: URI, stopId: string | undefined): URI {266return ChatEditingSnapshotTextModelContentProvider.getSnapshotFileURI(this.chatSessionResource, requestId, stopId, fileURI.path);267}268269private async _navigateToEpoch(restoreToEpoch: number, navigateToEpoch = restoreToEpoch): Promise<void> {270const currentEpoch = this._currentEpoch.get();271if (currentEpoch !== restoreToEpoch) {272const urisToRestore = await this._applyFileSystemOperations(currentEpoch, restoreToEpoch);273274// Reconstruct content for files affected by operations in the range275await this._reconstructAllFileContents(restoreToEpoch, urisToRestore);276}277278// Update current epoch279this._currentEpoch.set(navigateToEpoch, undefined);280}281282private _getCheckpoint(checkpointId: string): ICheckpoint | undefined {283return this._checkpoints.get().find(c => c.checkpointId === checkpointId);284}285286public incrementEpoch() {287return this._epochCounter++;288}289290public recordFileOperation(operation: FileOperation): void {291const { currentEpoch, checkpoints, operations } = this._getVisibleOperationsAndCheckpoints();292if (operation.epoch < currentEpoch) {293throw new Error(`Cannot record operation at epoch ${operation.epoch} when current epoch is ${currentEpoch}`);294}295296operations.push(operation);297transaction(tx => {298this._checkpoints.set(checkpoints, tx);299this._operations.set(operations, tx);300this._currentEpoch.set(operation.epoch + 1, tx);301});302}303304private _getVisibleOperationsAndCheckpoints() {305const currentEpoch = this._currentEpoch.get();306const checkpoints = this._checkpoints.get();307const operations = this._operations.get();308309return {310currentEpoch,311checkpoints: checkpoints.filter(c => c.epoch < currentEpoch),312operations: operations.filter(op => op.epoch < currentEpoch)313};314}315316public recordFileBaseline(baseline: IFileBaseline): void {317const key = this._getBaselineKey(baseline.uri, baseline.requestId);318this._fileBaselines.set(key, baseline);319}320321private _getFileBaseline(uri: URI, requestId: string): IFileBaseline | undefined {322const key = this._getBaselineKey(uri, requestId);323return this._fileBaselines.get(key);324}325326public hasFileBaseline(uri: URI, requestId: string): boolean {327const key = this._getBaselineKey(uri, requestId);328return this._fileBaselines.has(key) || this._operations.get().some(op =>329op.type === FileOperationType.Create && op.requestId === requestId && isEqual(uri, op.uri));330}331332public async getContentAtStop(requestId: string, contentURI: URI, stopId: string | undefined) {333let toEpoch: number | undefined;334if (stopId?.startsWith(STOP_ID_EPOCH_PREFIX)) {335toEpoch = Number(stopId.slice(STOP_ID_EPOCH_PREFIX.length));336} else {337toEpoch = this._checkpoints.get().find(c => c.requestId === requestId && c.undoStopId === stopId)?.epoch;338}339340// The content URI doesn't preserve the original scheme or authority. Look through341// to find the operation that touched that path to get its actual URI342const fileURI = this._getTimelineCanonicalUriForPath(contentURI);343344if (!toEpoch || !fileURI) {345return '';346}347348const baseline = await this._findBestBaselineForFile(fileURI, toEpoch, requestId);349if (!baseline) {350return '';351}352353const operations = this._getFileOperationsInRange(fileURI, baseline.epoch, toEpoch);354const replayed = await this._replayOperations(baseline, operations);355return replayed.exists ? replayed.content : undefined;356}357358private _getTimelineCanonicalUriForPath(contentURI: URI) {359for (const it of [this._fileBaselines.values(), this._operations.get()]) {360for (const thing of it) {361if (thing.uri.path === contentURI.path) {362return thing.uri;363}364}365}366367return undefined;368}369370/**371* Creates a callback that is invoked when data at the stop changes. This372* will not fire initially and may be debounced internally.373*/374public onDidChangeContentsAtStop(requestId: string, contentURI: URI, stopId: string | undefined, callback: (data: string) => void): IDisposable {375// The only case where we have data that updates is if we have an epoch pointer that's376// after our know epochs (e.g. pointing to the end file state after all operations).377// If this isn't the case, abort.378if (!stopId || !stopId.startsWith(STOP_ID_EPOCH_PREFIX)) {379return Disposable.None;380}381382const target = Number(stopId.slice(STOP_ID_EPOCH_PREFIX.length));383if (target <= this._epochCounter) {384return Disposable.None; // already finalized385}386387const store = new DisposableStore();388const scheduler = store.add(new ThrottledDelayer(500));389390store.add(Event.fromObservableLight(this._operations)(() => {391scheduler.trigger(async () => {392if (this._operations.get().at(-1)?.epoch! >= target) {393store.dispose();394}395396const content = await this.getContentAtStop(requestId, contentURI, stopId);397if (content !== undefined) {398callback(content);399}400});401}));402403return store;404}405406private _getCheckpointBeforeEpoch(epoch: number, reader?: IReader) {407return findLast(this._checkpoints.read(reader), c => c.epoch <= epoch);408}409410private async _reconstructFileState(uri: URI, targetEpoch: number): Promise<IReconstructedFileState> {411const targetCheckpoint = this._getCheckpointBeforeEpoch(targetEpoch);412if (!targetCheckpoint) {413throw new Error(`Checkpoint for epoch ${targetEpoch} not found`);414}415416// Find the most appropriate baseline for this file417const baseline = await this._findBestBaselineForFile(uri, targetEpoch, targetCheckpoint.requestId || '');418if (!baseline) {419// File doesn't exist at this checkpoint420return {421exists: false,422uri,423};424}425426// Get operations that affect this file from baseline to target checkpoint427const operations = this._getFileOperationsInRange(uri, baseline.epoch, targetEpoch);428429// Replay operations to reconstruct state430return this._replayOperations(baseline, operations);431}432433public getStateForPersistence(): IChatEditingTimelineState {434return {435checkpoints: this._checkpoints.get(),436currentEpoch: this._currentEpoch.get(),437fileBaselines: [...this._fileBaselines],438operations: this._operations.get(),439epochCounter: this._epochCounter,440};441}442443public restoreFromState(state: IChatEditingTimelineState, tx: ITransaction): void {444this._checkpoints.set(state.checkpoints, tx);445this._currentEpoch.set(state.currentEpoch, tx);446this._operations.set(state.operations.slice(), tx);447this._epochCounter = state.epochCounter;448449this._fileBaselines.clear();450for (const [key, baseline] of state.fileBaselines) {451this._fileBaselines.set(key, baseline);452}453}454455public getCheckpointIdForRequest(requestId: string, undoStopId?: string): string | undefined {456const checkpoints = this._checkpoints.get();457return checkpoints.find(c => c.requestId === requestId && c.undoStopId === undoStopId)?.checkpointId;458}459460private async _reconstructAllFileContents(targetEpoch: number, filesToReconstruct: ResourceSet): Promise<void> {461await Promise.all(Array.from(filesToReconstruct).map(async uri => {462const reconstructedState = await this._reconstructFileState(uri, targetEpoch);463if (reconstructedState.exists) {464await this._delegate.setContents(reconstructedState.uri, reconstructedState.content, reconstructedState.telemetryInfo);465}466}));467}468469private _getBaselineKey(uri: URI, requestId: string): string {470return `${uri.toString()}::${requestId}`;471}472473private async _findBestBaselineForFile(uri: URI, epoch: number, requestId: string): Promise<IFileBaseline | undefined> {474// First, iterate backwards through operations before the target checkpoint475// to see if the file was created/re-created more recently than any baseline476477let currentRequestId = requestId;478const operations = this._operations.get();479for (let i = operations.length - 1; i >= 0; i--) {480const operation = operations[i];481if (operation.epoch > epoch) {482continue;483}484485// If the file was just created, use that as its updated baseline486if (operation.type === FileOperationType.Create && isEqual(operation.uri, uri)) {487return {488uri: operation.uri,489requestId: operation.requestId,490content: operation.initialContent,491epoch: operation.epoch,492telemetryInfo: operation.telemetryInfo,493};494}495496// If the file was renamed to this URI, use its old contents as the baseline497if (operation.type === FileOperationType.Rename && isEqual(operation.newUri, uri)) {498const prev = await this._findBestBaselineForFile(operation.oldUri, operation.epoch, operation.requestId);499if (!prev) {500return undefined;501}502503504const operations = this._getFileOperationsInRange(operation.oldUri, prev.epoch, operation.epoch);505const replayed = await this._replayOperations(prev, operations);506return {507uri: uri,508epoch: operation.epoch,509content: replayed.exists ? replayed.content : '',510requestId: operation.requestId,511telemetryInfo: prev.telemetryInfo,512notebookViewType: replayed.exists ? replayed.notebookViewType : undefined,513};514}515516// When the request ID changes, check if we have a baseline for the current request517if (currentRequestId && operation.requestId !== currentRequestId) {518const baseline = this._getFileBaseline(uri, currentRequestId);519if (baseline) {520return baseline;521}522}523524currentRequestId = operation.requestId;525}526527// Check the final request ID for a baseline528return this._getFileBaseline(uri, currentRequestId);529}530531private _getFileOperationsInRange(uri: URI, fromEpoch: number, toEpoch: number): readonly FileOperation[] {532return this._operations.get().filter(op => {533const cellUri = CellUri.parse(op.uri);534return op.epoch >= fromEpoch &&535op.epoch < toEpoch &&536(isEqual(op.uri, uri) || (cellUri && isEqual(cellUri.notebook, uri)));537}).sort((a, b) => a.epoch - b.epoch);538}539540private async _replayOperations(baseline: IFileBaseline, operations: readonly FileOperation[]): Promise<IReconstructedFileState> {541let currentState: IReconstructedFileStateWithNotebook = {542exists: true,543content: baseline.content,544uri: baseline.uri,545telemetryInfo: baseline.telemetryInfo,546};547548if (baseline.notebookViewType) {549currentState.notebook = await this._notebookEditorModelResolverService.createUntitledNotebookTextModel(baseline.notebookViewType);550if (baseline.content) {551restoreNotebookSnapshot(currentState.notebook, baseline.content);552}553}554555for (const operation of operations) {556currentState = await this._applyOperationToState(currentState, operation, baseline.telemetryInfo);557}558559if (currentState.exists && currentState.notebook) {560const info = await this._notebookService.withNotebookDataProvider(currentState.notebook.viewType);561currentState.content = createNotebookSnapshot(currentState.notebook, info.serializer.options, this._configurationService);562currentState.notebook.dispose();563}564565return currentState;566}567568private async _applyOperationToState(state: IReconstructedFileStateWithNotebook, operation: FileOperation, telemetryInfo: IModifiedEntryTelemetryInfo): Promise<IReconstructedFileStateWithNotebook> {569switch (operation.type) {570case FileOperationType.Create: {571if (state.exists && state.notebook) {572state.notebook.dispose();573}574575let notebook: INotebookTextModel | undefined;576if (operation.notebookViewType) {577notebook = await this._notebookEditorModelResolverService.createUntitledNotebookTextModel(operation.notebookViewType);578if (operation.initialContent) {579restoreNotebookSnapshot(notebook, operation.initialContent);580}581}582583return {584exists: true,585content: operation.initialContent,586uri: operation.uri,587telemetryInfo,588notebookViewType: operation.notebookViewType,589notebook,590};591}592593case FileOperationType.Delete:594if (state.exists && state.notebook) {595state.notebook.dispose();596}597598return {599exists: false,600uri: operation.uri601};602603case FileOperationType.Rename:604return {605...state,606uri: operation.newUri607};608609case FileOperationType.TextEdit: {610if (!state.exists) {611throw new Error('Cannot apply text edits to non-existent file');612}613614const nbCell = operation.cellIndex !== undefined && state.notebook?.cells.at(operation.cellIndex);615if (nbCell) {616const newContent = this._applyTextEditsToContent(nbCell.getValue(), operation.edits);617state.notebook!.applyEdits([{618editType: CellEditType.Replace,619index: operation.cellIndex,620count: 1,621cells: [{ cellKind: nbCell.cellKind, language: nbCell.language, mime: nbCell.language, source: newContent, outputs: nbCell.outputs }]622}], true, undefined, () => undefined, undefined);623return state;624}625626// Apply text edits using a temporary text model627return {628...state,629content: this._applyTextEditsToContent(state.content, operation.edits)630};631}632case FileOperationType.NotebookEdit:633if (!state.exists) {634throw new Error('Cannot apply notebook edits to non-existent file');635}636if (!state.notebook) {637throw new Error('Cannot apply notebook edits to non-notebook file');638}639640state.notebook.applyEdits(operation.cellEdits.slice(), true, undefined, () => undefined, undefined);641return state;642643default:644assertNever(operation);645}646}647648private async _applyFileSystemOperations(fromEpoch: number, toEpoch: number): Promise<ResourceSet> {649const isMovingForward = toEpoch > fromEpoch;650const operations = this._operations.get().filter(op => {651if (isMovingForward) {652return op.epoch >= fromEpoch && op.epoch < toEpoch;653} else {654return op.epoch < fromEpoch && op.epoch >= toEpoch;655}656}).sort((a, b) => isMovingForward ? a.epoch - b.epoch : b.epoch - a.epoch);657658// Apply file system operations in the correct direction659const urisToRestore = new ResourceSet();660for (const operation of operations) {661await this._applyFileSystemOperation(operation, isMovingForward, urisToRestore);662}663664return urisToRestore;665}666667private async _applyFileSystemOperation(operation: FileOperation, isMovingForward: boolean, urisToRestore: ResourceSet): Promise<void> {668switch (operation.type) {669case FileOperationType.Create:670if (isMovingForward) {671await this._delegate.createFile(operation.uri, operation.initialContent);672urisToRestore.add(operation.uri);673} else {674await this._delegate.deleteFile(operation.uri);675urisToRestore.delete(operation.uri);676}677break;678679case FileOperationType.Delete:680if (isMovingForward) {681await this._delegate.deleteFile(operation.uri);682urisToRestore.delete(operation.uri);683} else {684await this._delegate.createFile(operation.uri, operation.finalContent);685urisToRestore.add(operation.uri);686}687break;688689case FileOperationType.Rename:690if (isMovingForward) {691await this._delegate.renameFile(operation.oldUri, operation.newUri);692urisToRestore.delete(operation.oldUri);693urisToRestore.add(operation.newUri);694} else {695await this._delegate.renameFile(operation.newUri, operation.oldUri);696urisToRestore.delete(operation.newUri);697urisToRestore.add(operation.oldUri);698}699break;700701// Text and notebook edits don't affect file system structure702case FileOperationType.TextEdit:703case FileOperationType.NotebookEdit:704urisToRestore.add(CellUri.parse(operation.uri)?.notebook ?? operation.uri);705break;706707default:708assertNever(operation);709}710}711712private _applyTextEditsToContent(content: string, edits: readonly TextEdit[]): string {713// Use the example pattern provided by the user714const makeModel = (uri: URI, contents: string) => this._instantiationService.createInstance(TextModel, contents, '', this._modelService.getCreationOptions('', uri, true), uri);715716// Create a temporary URI for the model717const tempUri = URI.from({ scheme: 'temp', path: `/temp-${Date.now()}.txt` });718const model = makeModel(tempUri, content);719720try {721// Apply edits722model.applyEdits(edits.map(edit => ({723range: {724startLineNumber: edit.range.startLineNumber,725startColumn: edit.range.startColumn,726endLineNumber: edit.range.endLineNumber,727endColumn: edit.range.endColumn728},729text: edit.text730})));731732return model.getValue();733} finally {734model.dispose();735}736}737738public getEntryDiffBetweenStops(uri: URI, requestId: string | undefined, stopId: string | undefined): IObservable<IEditSessionEntryDiff | undefined> {739const epochs = derivedOpts<{ start: ICheckpoint; end: ICheckpoint | undefined }>({ equalsFn: (a, b) => a.start === b.start && a.end === b.end }, reader => {740const checkpoints = this._checkpoints.read(reader);741const startIndex = checkpoints.findIndex(c => c.requestId === requestId && c.undoStopId === stopId);742return { start: checkpoints[startIndex], end: checkpoints[startIndex + 1] };743});744745return this._getEntryDiffBetweenEpochs(uri, `s\0${requestId}\0${stopId}`, epochs);746}747748/** Gets the epoch bounds of the request. If stopRequestId is undefined, gets ONLY the single request's bounds */749private _getRequestEpochBounds(startRequestId: string, stopRequestId?: string): IObservable<{ start: ICheckpoint; end: ICheckpoint | undefined }> {750return derivedOpts<{ start: ICheckpoint; end: ICheckpoint | undefined }>({ equalsFn: (a, b) => a.start === b.start && a.end === b.end }, reader => {751const checkpoints = this._checkpoints.read(reader);752const startIndex = checkpoints.findIndex(c => c.requestId === startRequestId);753const start = startIndex === -1 ? checkpoints[0] : checkpoints[startIndex];754755let end: ICheckpoint | undefined;756if (stopRequestId === undefined) {757end = findFirst(checkpoints, c => c.requestId !== startRequestId, startIndex + 1);758} else {759end = checkpoints.find(c => c.requestId === stopRequestId)760|| findFirst(checkpoints, c => c.requestId !== startRequestId, startIndex + 1)761|| checkpoints[checkpoints.length - 1];762}763764return { start, end };765});766}767768public getEntryDiffBetweenRequests(uri: URI, startRequestId: string, stopRequestId: string): IObservable<IEditSessionEntryDiff | undefined> {769return this._getEntryDiffBetweenEpochs(uri, `r\0${startRequestId}\0${stopRequestId}`, this._getRequestEpochBounds(startRequestId, stopRequestId));770}771772private _getEntryDiffBetweenEpochs(uri: URI, cacheKey: string, epochs: IObservable<{ start: ICheckpoint | undefined; end: ICheckpoint | undefined }>): IObservable<IEditSessionEntryDiff | undefined> {773const key = `${uri.toString()}\0${cacheKey}`;774let obs = this._refCountedDiffs.get(key);775776if (!obs) {777obs = this._getEntryDiffBetweenEpochsInner(778uri,779epochs,780() => this._refCountedDiffs.delete(key),781);782this._refCountedDiffs.set(key, obs);783}784785return obs;786}787788private _getEntryDiffBetweenEpochsInner(789uri: URI,790epochs: IObservable<{ start: ICheckpoint | undefined; end: ICheckpoint | undefined }>,791onLastObserverRemoved: () => void,792): IObservable<IEditSessionEntryDiff | undefined> {793type ModelRefsValue = { refs: { model: ITextModel; onChange: IObservable<void> }[]; isFinal: boolean; error?: unknown };794795const modelRefsPromise = derived(this, (reader) => {796const { start, end } = epochs.read(reader);797if (!start) { return undefined; }798799const store = reader.store.add(new DisposableStore());800const originalURI = this.getContentURIAtStop(start.requestId || START_REQUEST_EPOCH, uri, STOP_ID_EPOCH_PREFIX + start.epoch);801const modifiedURI = this.getContentURIAtStop(end?.requestId || start.requestId || START_REQUEST_EPOCH, uri, STOP_ID_EPOCH_PREFIX + (end?.epoch || Number.MAX_SAFE_INTEGER));802803const promise: Promise<ModelRefsValue> = Promise.all([804this._textModelService.createModelReference(originalURI),805this._textModelService.createModelReference(modifiedURI),806]).then(refs => {807if (store.isDisposed) {808refs.forEach(r => r.dispose());809} else {810refs.forEach(r => store.add(r));811}812813return {814refs: refs.map(r => ({815model: r.object.textEditorModel,816onChange: observableSignalFromEvent(this, r.object.textEditorModel.onDidChangeContent.bind(r.object.textEditorModel)),817})),818isFinal: !!end,819};820}).catch((error): ModelRefsValue => {821return { refs: [], isFinal: true, error };822});823824return {825originalURI,826modifiedURI,827promise: new ObservablePromise(promise),828};829});830831const diff = derived(reader => {832const modelsData = modelRefsPromise.read(reader);833if (!modelsData) {834return;835}836837const { originalURI, modifiedURI, promise } = modelsData;838const promiseData = promise?.promiseResult.read(reader);839if (!promiseData?.data) {840return { originalURI, modifiedURI, promise: undefined };841}842843const { refs, isFinal, error } = promiseData.data;844if (error) {845return { originalURI, modifiedURI, promise: new ObservablePromise(Promise.resolve(emptySessionEntryDiff(originalURI, modifiedURI))) };846}847848refs.forEach(m => m.onChange.read(reader)); // re-read when contents change849850return { originalURI, modifiedURI, promise: new ObservablePromise(this._computeDiff(originalURI, modifiedURI, !!isFinal)) };851});852853return derivedOpts({ onLastObserverRemoved }, reader => {854const result = diff.read(reader);855if (!result) {856return undefined;857}858859const promised = result.promise?.promiseResult.read(reader);860if (promised?.data) {861return promised.data;862}863864if (promised?.error) {865return emptySessionEntryDiff(result.originalURI, result.modifiedURI);866}867868return { ...emptySessionEntryDiff(result.originalURI, result.modifiedURI), isBusy: true };869});870}871872private _computeDiff(originalUri: URI, modifiedUri: URI, isFinal: boolean): Promise<IEditSessionEntryDiff> {873return this._editorWorkerService.computeDiff(874originalUri,875modifiedUri,876{ ignoreTrimWhitespace: false, computeMoves: false, maxComputationTimeMs: 3000 },877'advanced'878).then((diff): IEditSessionEntryDiff => {879const entryDiff: IEditSessionEntryDiff = {880originalURI: originalUri,881modifiedURI: modifiedUri,882identical: !!diff?.identical,883isFinal,884quitEarly: !diff || diff.quitEarly,885added: 0,886removed: 0,887isBusy: false,888};889if (diff) {890for (const change of diff.changes) {891entryDiff.removed += change.original.endLineNumberExclusive - change.original.startLineNumber;892entryDiff.added += change.modified.endLineNumberExclusive - change.modified.startLineNumber;893}894}895return entryDiff;896});897}898899public hasEditsInRequest(requestId: string, reader?: IReader): boolean {900for (const value of this._fileBaselines.values()) {901if (value.requestId === requestId) {902return true;903}904}905906for (const operation of this._operations.read(reader)) {907if (operation.requestId === requestId) {908return true;909}910}911912return false;913}914915public getDiffsForFilesInRequest(requestId: string): IObservable<readonly IEditSessionEntryDiff[]> {916const boundsObservable = this._getRequestEpochBounds(requestId);917const startEpochs = derivedOpts<ResourceMap<number>>({ equalsFn: mapsStrictEqualIgnoreOrder }, reader => {918const uris = new ResourceMap<number>();919for (const value of this._fileBaselines.values()) {920if (value.requestId === requestId) {921uris.set(value.uri, value.epoch);922}923}924925const bounds = boundsObservable.read(reader);926for (const operation of this._operations.read(reader)) {927if (operation.epoch < bounds.start.epoch) {928continue;929}930if (bounds.end && operation.epoch >= bounds.end.epoch) {931break;932}933934if (operation.type === FileOperationType.Create) {935uris.set(operation.uri, 0);936}937}938939return uris;940});941942943return this._getDiffsForFilesAtEpochs(startEpochs, boundsObservable.map(b => b.end));944}945946private _getDiffsForFilesAtEpochs(startEpochs: IObservable<ResourceMap<number>>, endCheckpointObs: IObservable<ICheckpoint | undefined>) {947// URIs are never removed from the set and we never adjust baselines backwards948// (history is immutable) so we can easily cache to avoid regenerating diffs when new files are added949const prevDiffs = new ResourceMap<IObservable<IEditSessionEntryDiff | undefined>>();950let prevEndCheckpoint: ICheckpoint | undefined = undefined;951952const perFileDiffs = derived(this, reader => {953const checkpoints = this._checkpoints.read(reader);954const firstCheckpoint = checkpoints[0];955if (!firstCheckpoint) {956return [];957}958959const endCheckpoint = endCheckpointObs.read(reader);960if (endCheckpoint !== prevEndCheckpoint) {961prevDiffs.clear();962prevEndCheckpoint = endCheckpoint;963}964965const uris = startEpochs.read(reader);966const diffs: IObservable<IEditSessionEntryDiff | undefined>[] = [];967968for (const [uri, epoch] of uris) {969const obs = prevDiffs.get(uri) ?? this._getEntryDiffBetweenEpochs(uri, `e\0${epoch}\0${endCheckpoint?.epoch}`,970constObservable({ start: checkpoints.findLast(cp => cp.epoch <= epoch) || firstCheckpoint, end: endCheckpoint }));971prevDiffs.set(uri, obs);972diffs.push(obs);973}974975return diffs;976});977978return perFileDiffs.map((diffs, reader) => {979return diffs.flatMap(d => d.read(reader)).filter(isDefined);980});981}982983public getDiffsForFilesInSession(): IObservable<readonly IEditSessionEntryDiff[]> {984const startEpochs = derivedOpts<ResourceMap<number>>({ equalsFn: mapsStrictEqualIgnoreOrder }, reader => {985const uris = new ResourceMap<number>();986for (const baseline of this._fileBaselines.values()) {987uris.set(baseline.uri, Math.min(baseline.epoch, uris.get(baseline.uri) ?? Number.MAX_SAFE_INTEGER));988}989for (const operation of this._operations.read(reader)) {990if (operation.type === FileOperationType.Create) {991uris.set(operation.uri, 0);992}993}994995return uris;996});997998return this._getDiffsForFilesAtEpochs(startEpochs, constObservable(undefined));999}10001001public getDiffForSession(): IObservable<IEditSessionDiffStats> {1002const fileDiffs = this.getDiffsForFilesInSession();1003return derived(reader => {1004const diffs = fileDiffs.read(reader);1005let added = 0;1006let removed = 0;1007for (const diff of diffs) {1008added += diff.added;1009removed += diff.removed;1010}1011return { added, removed };1012});1013}1014}101510161017