Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTimeline.ts
3296 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*--------------------------------------------------------------------------------------------*/4567import { equals as arraysEqual, binarySearch2 } from '../../../../../base/common/arrays.js';8import { findLast } from '../../../../../base/common/arraysFind.js';9import { Iterable } from '../../../../../base/common/iterator.js';10import { DisposableStore, thenRegisterOrDispose } from '../../../../../base/common/lifecycle.js';11import { ResourceMap } from '../../../../../base/common/map.js';12import { equals as objectsEqual } from '../../../../../base/common/objects.js';13import { derived, derivedOpts, IObservable, ITransaction, ObservablePromise, observableValue, transaction } from '../../../../../base/common/observable.js';14import { isEqual } from '../../../../../base/common/resources.js';15import { URI } from '../../../../../base/common/uri.js';16import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js';17import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';18import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';19import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';20import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';21import { IEditSessionEntryDiff, ISnapshotEntry } from '../../common/chatEditingService.js';22import { IChatRequestDisablement } from '../../common/chatModel.js';23import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js';24import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js';25import { IChatEditingSessionSnapshot, IChatEditingSessionStop } from './chatEditingSessionStorage.js';26import { ChatEditingModifiedNotebookDiff } from './notebook/chatEditingModifiedNotebookDiff.js';2728/**29* Timeline/undo-redo stack for ChatEditingSession.30*/31export class ChatEditingTimeline {32public static readonly POST_EDIT_STOP_ID = 'd19944f6-f46c-4e17-911b-79a8e843c7c0'; // randomly generated33public static createEmptySnapshot(undoStop: string | undefined): IChatEditingSessionStop {34return {35stopId: undoStop,36entries: new ResourceMap(),37};38}3940private readonly _linearHistory = observableValue<readonly IChatEditingSessionSnapshot[]>(this, []);41private readonly _linearHistoryIndex = observableValue<number>(this, 0);4243private readonly _diffsBetweenStops = new Map<string, IObservable<IEditSessionEntryDiff | undefined>>();44private readonly _fullDiffs = new Map<string, IObservable<IEditSessionEntryDiff | undefined>>();45private readonly _ignoreTrimWhitespaceObservable: IObservable<boolean>;4647public readonly canUndo: IObservable<boolean>;48public readonly canRedo: IObservable<boolean>;4950public readonly requestDisablement = derivedOpts<IChatRequestDisablement[]>({ equalsFn: (a, b) => arraysEqual(a, b, objectsEqual) }, reader => {51const history = this._linearHistory.read(reader);52const index = this._linearHistoryIndex.read(reader);53const undoRequests: IChatRequestDisablement[] = [];54for (const entry of history) {55if (!entry.requestId) {56// ignored57} else if (entry.startIndex >= index) {58undoRequests.push({ requestId: entry.requestId });59} else if (entry.startIndex + entry.stops.length > index) {60undoRequests.push({ requestId: entry.requestId, afterUndoStop: entry.stops[(index - 1) - entry.startIndex].stopId });61}62}63return undoRequests;64});6566constructor(67@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,68@IInstantiationService private readonly _instantiationService: IInstantiationService,69@IConfigurationService configurationService: IConfigurationService,70@ITextModelService private readonly _textModelService: ITextModelService,71) {72this._ignoreTrimWhitespaceObservable = observableConfigValue('diffEditor.ignoreTrimWhitespace', true, configurationService);7374this.canUndo = derived(r => {75const linearHistoryIndex = this._linearHistoryIndex.read(r);76return linearHistoryIndex > 1;77});78this.canRedo = derived(r => {79const linearHistoryIndex = this._linearHistoryIndex.read(r);80return linearHistoryIndex < getMaxHistoryIndex(this._linearHistory.read(r));81});82}8384/**85* Restore the timeline from a saved state (history array and index).86*/87public restoreFromState(state: { history: readonly IChatEditingSessionSnapshot[]; index: number }, tx: ITransaction): void {88this._linearHistory.set(state.history, tx);89this._linearHistoryIndex.set(state.index, tx);90}9192/**93* Get the snapshot and history index for restoring, given requestId and stopId.94* If requestId is undefined, returns undefined (pending snapshot is managed by session).95*/96public getSnapshotForRestore(requestId: string | undefined, stopId: string | undefined): { stop: IChatEditingSessionStop; apply(): void } | undefined {97if (requestId === undefined) {98return undefined;99}100const stopRef = this.findEditStop(requestId, stopId);101if (!stopRef) {102return undefined;103}104105// When rolling back to the first snapshot taken for a request, mark the106// entire request as undone.107const toIndex = stopRef.stop.stopId === undefined ? stopRef.historyIndex : stopRef.historyIndex + 1;108return {109stop: stopRef.stop,110apply: () => this._linearHistoryIndex.set(toIndex, undefined)111};112}113114/**115* Ensures the state of the file in the given snapshot matches the current116* state of the {@param entry}. This is used to handle concurrent file edits.117*118* Given the case of two different edits, we will place and undo stop right119* before we `textEditGroup` in the underlying markdown stream, but at the120* time those are added the edits haven't been made yet, so both files will121* simply have the unmodified state.122*123* This method is called after each edit, so after the first file finishes124* being edits, it will update its content in the second undo snapshot such125* that it can be undone successfully.126*127* We ensure that the same file is not concurrently edited via the128* {@link _streamingEditLocks}, avoiding race conditions.129*130* @param next If true, this will edit the snapshot _after_ the undo stop131*/132public ensureEditInUndoStopMatches(133requestId: string,134undoStop: string | undefined,135entry: Pick<AbstractChatEditingModifiedFileEntry, 'modifiedURI' | 'createSnapshot' | 'equalsSnapshot'>,136next: boolean,137tx: ITransaction | undefined138) {139const history = this._linearHistory.get();140const snapIndex = history.findIndex((s) => s.requestId === requestId);141if (snapIndex === -1) {142return;143}144145const snap = { ...history[snapIndex] };146let stopIndex = snap.stops.findIndex((s) => s.stopId === undoStop);147if (stopIndex === -1) {148return;149}150151let linearHistoryIndexIncr = 0;152if (next) {153if (stopIndex === snap.stops.length - 1) {154if (snap.stops[stopIndex].stopId === ChatEditingTimeline.POST_EDIT_STOP_ID) {155throw new Error('cannot duplicate post-edit stop');156}157158snap.stops = snap.stops.concat(ChatEditingTimeline.createEmptySnapshot(ChatEditingTimeline.POST_EDIT_STOP_ID));159linearHistoryIndexIncr++;160}161stopIndex++;162}163164const stop = snap.stops[stopIndex];165if (entry.equalsSnapshot(stop.entries.get(entry.modifiedURI))) {166return;167}168169const newMap = new ResourceMap(stop.entries);170newMap.set(entry.modifiedURI, entry.createSnapshot(requestId, stop.stopId));171172const newStop = snap.stops.slice();173newStop[stopIndex] = { ...stop, entries: newMap };174snap.stops = newStop;175176const newHistory = history.slice();177newHistory[snapIndex] = snap;178179this._linearHistory.set(newHistory, tx);180if (linearHistoryIndexIncr) {181this._linearHistoryIndex.set(this._linearHistoryIndex.get() + linearHistoryIndexIncr, tx);182}183}184185/**186* Get the undo snapshot (previous in history), or undefined if at start.187* If the timeline is at the end of the history, it will return the last stop188* pushed into the history.189*/190public getUndoSnapshot(): { stop: IChatEditingSessionStop; apply(): void } | undefined {191return this.getUndoRedoSnapshot(-1);192}193194/**195* Get the redo snapshot (next in history), or undefined if at end.196*/197public getRedoSnapshot(): { stop: IChatEditingSessionStop; apply(): void } | undefined {198return this.getUndoRedoSnapshot(1);199}200201private getUndoRedoSnapshot(direction: number) {202let idx = this._linearHistoryIndex.get() - 1;203const max = getMaxHistoryIndex(this._linearHistory.get());204const startEntry = this.getHistoryEntryByLinearIndex(idx);205let entry = startEntry;206if (!startEntry) {207return undefined;208}209210do {211idx += direction;212entry = this.getHistoryEntryByLinearIndex(idx);213} while (214idx + direction < max &&215idx + direction >= 0 &&216entry &&217!(direction === -1 && entry.entry.requestId !== startEntry.entry.requestId) &&218!stopProvidesNewData(startEntry.stop, entry.stop)219);220221if (entry) {222return { stop: entry.stop, apply: () => this._linearHistoryIndex.set(idx + 1, undefined) };223}224225return undefined;226}227228/**229* Get the state for persistence (history and index).230*/231public getStateForPersistence(): { history: readonly IChatEditingSessionSnapshot[]; index: number } {232return { history: this._linearHistory.get(), index: this._linearHistoryIndex.get() };233}234235private findSnapshot(requestId: string): IChatEditingSessionSnapshot | undefined {236return this._linearHistory.get().find((s) => s.requestId === requestId);237}238239private findEditStop(requestId: string, undoStop: string | undefined) {240const snapshot = this.findSnapshot(requestId);241if (!snapshot) {242return undefined;243}244const idx = snapshot.stops.findIndex((s) => s.stopId === undoStop);245return idx === -1 ? undefined : { stop: snapshot.stops[idx], snapshot, historyIndex: snapshot.startIndex + idx };246}247248private getHistoryEntryByLinearIndex(index: number) {249const history = this._linearHistory.get();250const searchedIndex = binarySearch2(history.length, (e) => history[e].startIndex - index);251const entry = history[searchedIndex < 0 ? (~searchedIndex) - 1 : searchedIndex];252if (!entry || index - entry.startIndex >= entry.stops.length) {253return undefined;254}255return {256entry,257stop: entry.stops[index - entry.startIndex]258};259}260261public pushSnapshot(requestId: string, undoStop: string | undefined, snapshot: IChatEditingSessionStop) {262const linearHistoryPtr = this._linearHistoryIndex.get();263const newLinearHistory: IChatEditingSessionSnapshot[] = [];264for (const entry of this._linearHistory.get()) {265if (entry.startIndex >= linearHistoryPtr) {266break;267} else if (linearHistoryPtr - entry.startIndex < entry.stops.length) {268newLinearHistory.push({ requestId: entry.requestId, stops: entry.stops.slice(0, linearHistoryPtr - entry.startIndex), startIndex: entry.startIndex });269} else {270newLinearHistory.push(entry);271}272}273274const lastEntry = newLinearHistory.at(-1);275if (requestId && lastEntry?.requestId === requestId) {276const hadPostEditStop = lastEntry.stops.at(-1)?.stopId === ChatEditingTimeline.POST_EDIT_STOP_ID && undoStop;277if (hadPostEditStop) {278const rebaseUri = (uri: URI) => uri.with({ query: uri.query.replace(ChatEditingTimeline.POST_EDIT_STOP_ID, undoStop) });279for (const [uri, prev] of lastEntry.stops.at(-1)!.entries) {280snapshot.entries.set(uri, { ...prev, snapshotUri: rebaseUri(prev.snapshotUri), resource: rebaseUri(prev.resource) });281}282}283newLinearHistory[newLinearHistory.length - 1] = {284...lastEntry,285stops: [...hadPostEditStop ? lastEntry.stops.slice(0, -1) : lastEntry.stops, snapshot]286};287} else {288newLinearHistory.push({ requestId, startIndex: lastEntry ? lastEntry.startIndex + lastEntry.stops.length : 0, stops: [snapshot] });289}290291transaction((tx) => {292const last = newLinearHistory[newLinearHistory.length - 1];293this._linearHistory.set(newLinearHistory, tx);294this._linearHistoryIndex.set(last.startIndex + last.stops.length, tx);295});296}297298/**299* Gets diff for text entries between stops.300* @param entriesContent Observable that observes either snapshot entry301* @param modelUrisObservable Observable that observes only the snapshot URIs.302*/303private _entryDiffBetweenTextStops(304entriesContent: IObservable<{ before: ISnapshotEntry; after: ISnapshotEntry } | undefined>,305modelUrisObservable: IObservable<[URI, URI] | undefined>,306): IObservable<ObservablePromise<IEditSessionEntryDiff> | undefined> {307const modelRefsPromise = derived(this, (reader) => {308const modelUris = modelUrisObservable.read(reader);309if (!modelUris) { return undefined; }310311const store = reader.store.add(new DisposableStore());312const promise = Promise.all(modelUris.map(u => this._textModelService.createModelReference(u))).then(refs => {313if (store.isDisposed) {314refs.forEach(r => r.dispose());315} else {316refs.forEach(r => store.add(r));317}318319return refs;320});321322return new ObservablePromise(promise);323});324325return derived((reader): ObservablePromise<IEditSessionEntryDiff> | undefined => {326const refs2 = modelRefsPromise.read(reader)?.promiseResult.read(reader);327const refs = refs2?.data;328if (!refs) {329return;330}331332const entries = entriesContent.read(reader); // trigger re-diffing when contents change333334if (entries?.before && ChatEditingModifiedNotebookEntry.canHandleSnapshot(entries.before)) {335const diffService = this._instantiationService.createInstance(ChatEditingModifiedNotebookDiff, entries.before, entries.after);336return new ObservablePromise(diffService.computeDiff());337338}339const ignoreTrimWhitespace = this._ignoreTrimWhitespaceObservable.read(reader);340const promise = this._computeDiff(refs[0].object.textEditorModel.uri, refs[1].object.textEditorModel.uri, ignoreTrimWhitespace);341342return new ObservablePromise(promise);343});344}345346private _createDiffBetweenStopsObservable(uri: URI, requestId: string | undefined, stopId: string | undefined): IObservable<IEditSessionEntryDiff | undefined> {347const entries = derivedOpts<undefined | { before: ISnapshotEntry; after: ISnapshotEntry }>(348{349equalsFn: (a, b) => snapshotsEqualForDiff(a?.before, b?.before) && snapshotsEqualForDiff(a?.after, b?.after),350},351reader => {352const stops = requestId ?353getCurrentAndNextStop(requestId, stopId, this._linearHistory.read(reader)) :354getFirstAndLastStop(uri, this._linearHistory.read(reader));355if (!stops) { return undefined; }356const before = stops.current.get(uri);357const after = stops.next.get(uri);358if (!before || !after) { return undefined; }359return { before, after };360},361);362363// Separate observable for model refs to avoid unnecessary disposal364const modelUrisObservable = derivedOpts<[URI, URI] | undefined>({ equalsFn: (a, b) => arraysEqual(a, b, isEqual) }, reader => {365const entriesValue = entries.read(reader);366if (!entriesValue) { return undefined; }367return [entriesValue.before.snapshotUri, entriesValue.after.snapshotUri];368});369370const diff = this._entryDiffBetweenTextStops(entries, modelUrisObservable);371372return derived(reader => {373return diff.read(reader)?.promiseResult.read(reader)?.data || undefined;374});375}376377public getEntryDiffBetweenStops(uri: URI, requestId: string | undefined, stopId: string | undefined) {378if (requestId) {379const key = `${uri}\0${requestId}\0${stopId}`;380let observable = this._diffsBetweenStops.get(key);381if (!observable) {382observable = this._createDiffBetweenStopsObservable(uri, requestId, stopId);383this._diffsBetweenStops.set(key, observable);384}385386return observable;387} else {388const key = uri.toString();389let observable = this._fullDiffs.get(key);390if (!observable) {391observable = this._createDiffBetweenStopsObservable(uri, requestId, stopId);392this._fullDiffs.set(key, observable);393}394395return observable;396}397}398399public getEntryDiffBetweenRequests(uri: URI, startRequestId: string, stopRequestId: string): IObservable<IEditSessionEntryDiff | undefined> {400const snapshotUris = derivedOpts<[URI | undefined, URI | undefined]>(401{ equalsFn: (a, b) => arraysEqual(a, b, isEqual) },402reader => {403const history = this._linearHistory.read(reader);404const firstSnapshotUri = this._getFirstSnapshotForUriAfterRequest(history, uri, startRequestId, true);405const lastSnapshotUri = this._getFirstSnapshotForUriAfterRequest(history, uri, stopRequestId, false);406return [firstSnapshotUri, lastSnapshotUri];407},408);409const modelRefs = derived((reader) => {410const snapshots = snapshotUris.read(reader);411const firstSnapshotUri = snapshots[0];412const lastSnapshotUri = snapshots[1];413if (!firstSnapshotUri || !lastSnapshotUri) {414return;415}416const store = new DisposableStore();417reader.store.add(store);418const referencesPromise = Promise.all([firstSnapshotUri, lastSnapshotUri].map(u => {419return thenRegisterOrDispose(this._textModelService.createModelReference(u), store);420}));421return new ObservablePromise(referencesPromise);422});423const diff = derived((reader): ObservablePromise<IEditSessionEntryDiff> | undefined => {424const references = modelRefs.read(reader)?.promiseResult.read(reader);425const refs = references?.data;426if (!refs) {427return;428}429const ignoreTrimWhitespace = this._ignoreTrimWhitespaceObservable.read(reader);430const promise = this._computeDiff(refs[0].object.textEditorModel.uri, refs[1].object.textEditorModel.uri, ignoreTrimWhitespace);431return new ObservablePromise(promise);432});433return derived(reader => {434return diff.read(reader)?.promiseResult.read(reader)?.data || undefined;435});436}437438private _computeDiff(originalUri: URI, modifiedUri: URI, ignoreTrimWhitespace: boolean): Promise<IEditSessionEntryDiff> {439return this._editorWorkerService.computeDiff(440originalUri,441modifiedUri,442{ ignoreTrimWhitespace, computeMoves: false, maxComputationTimeMs: 3000 },443'advanced'444).then((diff): IEditSessionEntryDiff => {445const entryDiff: IEditSessionEntryDiff = {446originalURI: originalUri,447modifiedURI: modifiedUri,448identical: !!diff?.identical,449quitEarly: !diff || diff.quitEarly,450added: 0,451removed: 0,452};453if (diff) {454for (const change of diff.changes) {455entryDiff.removed += change.original.endLineNumberExclusive - change.original.startLineNumber;456entryDiff.added += change.modified.endLineNumberExclusive - change.modified.startLineNumber;457}458}459return entryDiff;460});461}462463private _getFirstSnapshotForUriAfterRequest(history: readonly IChatEditingSessionSnapshot[], uri: URI, requestId: string, inclusive: boolean): URI | undefined {464const requestIndex = history.findIndex(s => s.requestId === requestId);465if (requestIndex === -1) { return undefined; }466const processedIndex = requestIndex + (inclusive ? 0 : 1);467for (let i = processedIndex; i < history.length; i++) {468const snapshot = history[i];469for (const stop of snapshot.stops) {470const entry = stop.entries.get(uri);471if (entry) {472return entry.snapshotUri;473}474}475}476return uri;477}478}479480function stopProvidesNewData(origin: IChatEditingSessionStop, target: IChatEditingSessionStop) {481return Iterable.some(target.entries, ([uri, e]) => origin.entries.get(uri)?.current !== e.current);482}483484function getMaxHistoryIndex(history: readonly IChatEditingSessionSnapshot[]) {485const lastHistory = history.at(-1);486return lastHistory ? lastHistory.startIndex + lastHistory.stops.length : 0;487}488489function snapshotsEqualForDiff(a: ISnapshotEntry | undefined, b: ISnapshotEntry | undefined) {490if (!a || !b) {491return a === b;492}493494return isEqual(a.snapshotUri, b.snapshotUri) && a.current === b.current;495}496497function getCurrentAndNextStop(requestId: string, stopId: string | undefined, history: readonly IChatEditingSessionSnapshot[]) {498const snapshotIndex = history.findIndex(s => s.requestId === requestId);499if (snapshotIndex === -1) { return undefined; }500const snapshot = history[snapshotIndex];501const stopIndex = snapshot.stops.findIndex(s => s.stopId === stopId);502if (stopIndex === -1) { return undefined; }503504const currentStop = snapshot.stops[stopIndex];505const current = currentStop.entries;506const nextStop = stopIndex < snapshot.stops.length - 1507? snapshot.stops[stopIndex + 1]508: undefined;509if (!nextStop) {510return undefined;511}512513return { current, currentStopId: currentStop.stopId, next: nextStop.entries, nextStopId: nextStop.stopId };514}515516function getFirstAndLastStop(uri: URI, history: readonly IChatEditingSessionSnapshot[]) {517let firstStopWithUri: IChatEditingSessionStop | undefined;518for (const snapshot of history) {519const stop = snapshot.stops.find(s => s.entries.has(uri));520if (stop) {521firstStopWithUri = stop;522break;523}524}525526let lastStopWithUri: ResourceMap<ISnapshotEntry> | undefined;527let lastStopWithUriId: string | undefined;528for (let i = history.length - 1; i >= 0; i--) {529const snapshot = history[i];530const stop = findLast(snapshot.stops, s => s.entries.has(uri));531if (stop) {532lastStopWithUri = stop.entries;533lastStopWithUriId = stop.stopId;534break;535}536}537538if (!firstStopWithUri || !lastStopWithUri) {539return undefined;540}541542return { current: firstStopWithUri.entries, currentStopId: firstStopWithUri.stopId, next: lastStopWithUri, nextStopId: lastStopWithUriId! };543}544545546