Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts
5257 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 { DeferredPromise, ITask, Sequencer, SequencerByKey, timeout } from '../../../../../base/common/async.js';6import { VSBuffer } from '../../../../../base/common/buffer.js';7import { CancellationToken } from '../../../../../base/common/cancellation.js';8import { BugIndicatingError } from '../../../../../base/common/errors.js';9import { Emitter } from '../../../../../base/common/event.js';10import { MarkdownString } from '../../../../../base/common/htmlContent.js';11import { Iterable } from '../../../../../base/common/iterator.js';12import { Disposable, DisposableStore, dispose } from '../../../../../base/common/lifecycle.js';13import { ResourceMap } from '../../../../../base/common/map.js';14import { derived, IObservable, IReader, ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js';15import { isEqual } from '../../../../../base/common/resources.js';16import { hasKey, Mutable } from '../../../../../base/common/types.js';17import { URI } from '../../../../../base/common/uri.js';18import { IBulkEditService } from '../../../../../editor/browser/services/bulkEditService.js';19import { Range } from '../../../../../editor/common/core/range.js';20import { TextEdit } from '../../../../../editor/common/languages.js';21import { ILanguageService } from '../../../../../editor/common/languages/language.js';22import { ITextModel } from '../../../../../editor/common/model.js';23import { IModelService } from '../../../../../editor/common/services/model.js';24import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';25import { localize } from '../../../../../nls.js';26import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';27import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';28import { EditorActivation } from '../../../../../platform/editor/common/editor.js';29import { IFileService } from '../../../../../platform/files/common/files.js';30import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';31import { ILogService } from '../../../../../platform/log/common/log.js';32import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js';33import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js';34import { IEditorService } from '../../../../services/editor/common/editorService.js';35import { MultiDiffEditor } from '../../../multiDiffEditor/browser/multiDiffEditor.js';36import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js';37import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js';38import { INotebookService } from '../../../notebook/common/notebookService.js';39import { chatEditingSessionIsReady, ChatEditingSessionState, ChatEditKind, getMultiDiffSourceUri, IChatEditingSession, IEditSessionEntryDiff, IModifiedEntryTelemetryInfo, IModifiedFileEntry, ISnapshotEntry, IStreamingEdits, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js';40import { IChatResponseModel } from '../../common/model/chatModel.js';41import { IChatProgress, IChatWorkspaceEdit } from '../../common/chatService/chatService.js';42import { ChatAgentLocation } from '../../common/constants.js';43import { IChatEditingCheckpointTimeline } from './chatEditingCheckpointTimeline.js';44import { ChatEditingCheckpointTimelineImpl, IChatEditingTimelineFsDelegate } from './chatEditingCheckpointTimelineImpl.js';45import { ChatEditingDeletedFileEntry } from './chatEditingDeletedFileEntry.js';46import { ChatEditingModifiedDocumentEntry } from './chatEditingModifiedDocumentEntry.js';47import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js';48import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js';49import { FileOperation, FileOperationType } from './chatEditingOperations.js';50import { IChatEditingExplanationModelManager, IExplanationDiffInfo, IExplanationGenerationHandle } from './chatEditingExplanationModelManager.js';51import { ChatEditingSessionStorage, IChatEditingSessionStop, StoredSessionState } from './chatEditingSessionStorage.js';52import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js';53import { getChatSessionType } from '../../common/model/chatUri.js';54import { AgentSessionProviders } from '../agentSessions/agentSessions.js';5556const enum NotExistBehavior {57Create,58Abort,59}6061class ThrottledSequencer extends Sequencer {6263private _size = 0;6465constructor(66private readonly _minDuration: number,67private readonly _maxOverallDelay: number68) {69super();70}7172override queue<T>(promiseTask: ITask<Promise<T>>): Promise<T> {7374this._size += 1;7576const noDelay = this._size * this._minDuration > this._maxOverallDelay;7778return super.queue(async () => {79try {80const p1 = promiseTask();81const p2 = noDelay82? Promise.resolve(undefined)83: timeout(this._minDuration, CancellationToken.None);8485const [result] = await Promise.all([p1, p2]);86return result;8788} finally {89this._size -= 1;90}91});92}93}9495function createOpeningEditCodeBlock(uri: URI, isNotebook: boolean, undoStopId: string): IChatProgress[] {96return [97{98kind: 'markdownContent',99content: new MarkdownString('\n````\n')100},101{102kind: 'codeblockUri',103uri,104isEdit: true,105undoStopId106},107{108kind: 'markdownContent',109content: new MarkdownString('\n````\n')110},111isNotebook112? {113kind: 'notebookEdit',114uri,115edits: [],116done: false,117isExternalEdit: true118}119: {120kind: 'textEdit',121uri,122edits: [],123done: false,124isExternalEdit: true125},126];127}128129130export class ChatEditingSession extends Disposable implements IChatEditingSession {131private readonly _state = observableValue<ChatEditingSessionState>(this, ChatEditingSessionState.Initial);132private readonly _timeline: IChatEditingCheckpointTimeline;133134/**135* Contains the contents of a file when the AI first began doing edits to it.136*/137private readonly _initialFileContents = new ResourceMap<string>();138139private readonly _baselineCreationLocks = new SequencerByKey</* URI.path */ string>();140private readonly _streamingEditLocks = new SequencerByKey</* URI */ string>();141142/**143* Tracks active external edit operations.144* Key is operationId, value contains the operation state.145*/146private readonly _externalEditOperations = new Map<number, {147responseModel: IChatResponseModel;148snapshots: ResourceMap<string | undefined>;149undoStopId: string;150releaseLocks: () => void;151}>();152153private readonly _entriesObs = observableValue<readonly AbstractChatEditingModifiedFileEntry[]>(this, []);154public readonly entries: IObservable<readonly IModifiedFileEntry[]> = derived(reader => {155const state = this._state.read(reader);156if (state === ChatEditingSessionState.Disposed || state === ChatEditingSessionState.Initial) {157return [];158} else {159return this._entriesObs.read(reader);160}161});162163private _editorPane: MultiDiffEditor | undefined;164private _explanationHandle: IExplanationGenerationHandle | undefined;165166get state(): IObservable<ChatEditingSessionState> {167return this._state;168}169170public readonly canUndo: IObservable<boolean>;171public readonly canRedo: IObservable<boolean>;172173public get requestDisablement() {174return this._timeline.requestDisablement;175}176177private readonly _onDidDispose = new Emitter<void>();178get onDidDispose() {179this._assertNotDisposed();180return this._onDidDispose.event;181}182183constructor(184readonly chatSessionResource: URI,185readonly isGlobalEditingSession: boolean,186private _lookupExternalEntry: (uri: URI) => AbstractChatEditingModifiedFileEntry | undefined,187transferFrom: IChatEditingSession | undefined,188@IInstantiationService private readonly _instantiationService: IInstantiationService,189@IModelService private readonly _modelService: IModelService,190@ILanguageService private readonly _languageService: ILanguageService,191@ITextModelService private readonly _textModelService: ITextModelService,192@IBulkEditService public readonly _bulkEditService: IBulkEditService,193@IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService,194@IEditorService private readonly _editorService: IEditorService,195@INotebookService private readonly _notebookService: INotebookService,196@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService,197@ILogService private readonly _logService: ILogService,198@IConfigurationService private readonly configurationService: IConfigurationService,199@IFileService private readonly _fileService: IFileService,200@IChatEditingExplanationModelManager private readonly _explanationModelManager: IChatEditingExplanationModelManager,201) {202super();203this._timeline = this._instantiationService.createInstance(204ChatEditingCheckpointTimelineImpl,205chatSessionResource,206this._getTimelineDelegate(),207);208209this.canRedo = this._timeline.canRedo.map((hasHistory, reader) =>210hasHistory && this._state.read(reader) === ChatEditingSessionState.Idle);211this.canUndo = this._timeline.canUndo.map((hasHistory, reader) =>212hasHistory && this._state.read(reader) === ChatEditingSessionState.Idle);213214this._init(transferFrom);215}216217private _getTimelineDelegate(): IChatEditingTimelineFsDelegate {218return {219createFile: (uri, content) => {220return this._bulkEditService.apply({221edits: [{222newResource: uri,223options: {224overwrite: true,225contents: content ? Promise.resolve(VSBuffer.fromString(content)) : undefined,226},227}],228});229},230deleteFile: async (uri) => {231const entries = this._entriesObs.get().filter(e => !isEqual(e.modifiedURI, uri));232this._entriesObs.set(entries, undefined);233await this._bulkEditService.apply({ edits: [{ oldResource: uri, options: { ignoreIfNotExists: true } }] });234},235renameFile: async (fromUri, toUri) => {236const entries = this._entriesObs.get();237const previousEntry = entries.find(e => isEqual(e.modifiedURI, fromUri));238if (previousEntry) {239const newEntry = await this._getOrCreateModifiedFileEntry(toUri, NotExistBehavior.Create, previousEntry.telemetryInfo, this._getCurrentTextOrNotebookSnapshot(previousEntry));240previousEntry.dispose();241this._entriesObs.set(entries.map(e => e === previousEntry ? newEntry : e), undefined);242}243},244setContents: async (uri, content, telemetryInfo) => {245const entry = await this._getOrCreateModifiedFileEntry(uri, NotExistBehavior.Create, telemetryInfo);246247// We apply these edits as 'agent edits' which will by default make them get keep248// /undo indicators. This is good in the case the edits were never initially accepted,249// but if the file was already in an accepted state we should not make it modified again.250const state = entry.state.get();251if (entry instanceof ChatEditingModifiedNotebookEntry) {252await entry.restoreModifiedModelFromSnapshot(content);253} else {254await entry.acceptAgentEdits(uri, [{ range: new Range(1, 1, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER), text: content }], true, undefined);255}256257if (state !== ModifiedFileEntryState.Modified) {258await entry.accept();259}260}261};262}263264private async _init(transferFrom?: IChatEditingSession): Promise<void> {265const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource);266let restoredSessionState: StoredSessionState | undefined;267if (transferFrom instanceof ChatEditingSession) {268restoredSessionState = transferFrom._getStoredState(this.chatSessionResource);269} else {270restoredSessionState = await storage.restoreState().catch(err => {271this._logService.error(`Error restoring chat editing session state for ${this.chatSessionResource}`, err);272return undefined;273});274275if (this._store.isDisposed) {276return; // disposed while restoring277}278}279280281if (restoredSessionState) {282for (const [uri, content] of restoredSessionState.initialFileContents) {283this._initialFileContents.set(uri, content);284}285if (restoredSessionState.timeline) {286transaction(tx => this._timeline.restoreFromState(restoredSessionState.timeline!, tx));287}288await this._initEntries(restoredSessionState.recentSnapshot);289}290291this._state.set(ChatEditingSessionState.Idle, undefined);292}293294private _getEntry(uri: URI): AbstractChatEditingModifiedFileEntry | undefined {295uri = CellUri.parse(uri)?.notebook ?? uri;296return this._entriesObs.get().find(e => isEqual(e.modifiedURI, uri));297}298299public getEntry(uri: URI): IModifiedFileEntry | undefined {300return this._getEntry(uri);301}302303public readEntry(uri: URI, reader: IReader | undefined): IModifiedFileEntry | undefined {304uri = CellUri.parse(uri)?.notebook ?? uri;305return this._entriesObs.read(reader).find(e => isEqual(e.modifiedURI, uri));306}307308public storeState(): Promise<void> {309const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource);310return storage.storeState(this._getStoredState());311}312313private _getStoredState(sessionResource = this.chatSessionResource): StoredSessionState {314const entries = new ResourceMap<ISnapshotEntry>();315for (const entry of this._entriesObs.get()) {316entries.set(entry.modifiedURI, entry.createSnapshot(sessionResource, undefined, undefined));317}318319const state: StoredSessionState = {320initialFileContents: this._initialFileContents,321timeline: this._timeline.getStateForPersistence(),322recentSnapshot: { entries, stopId: undefined },323};324325return state;326}327328public getEntryDiffBetweenStops(uri: URI, requestId: string | undefined, stopId: string | undefined) {329return this._timeline.getEntryDiffBetweenStops(uri, requestId, stopId);330}331332public getEntryDiffBetweenRequests(uri: URI, startRequestId: string, stopRequestId: string) {333return this._timeline.getEntryDiffBetweenRequests(uri, startRequestId, stopRequestId);334}335336public getDiffsForFilesInSession() {337return this._timeline.getDiffsForFilesInSession();338}339340public getDiffForSession() {341return this._timeline.getDiffForSession();342}343344public getDiffsForFilesInRequest(requestId: string): IObservable<readonly IEditSessionEntryDiff[]> {345return this._timeline.getDiffsForFilesInRequest(requestId);346}347348public hasEditsInRequest(requestId: string, reader?: IReader): boolean {349return this._timeline.hasEditsInRequest(requestId, reader);350}351352public createSnapshot(requestId: string, undoStop: string | undefined): void {353const label = undoStop ? `Request ${requestId} - Stop ${undoStop}` : `Request ${requestId}`;354this._timeline.createCheckpoint(requestId, undoStop, label);355}356357public async getSnapshotContents(requestId: string, uri: URI, stopId: string | undefined): Promise<VSBuffer | undefined> {358const content = await this._timeline.getContentAtStop(requestId, uri, stopId);359return typeof content === 'string' ? VSBuffer.fromString(content) : content;360}361362public async getSnapshotModel(requestId: string, undoStop: string | undefined, snapshotUri: URI): Promise<ITextModel | null> {363await this._baselineCreationLocks.peek(snapshotUri.path);364365const content = await this._timeline.getContentAtStop(requestId, snapshotUri, undoStop);366if (content === undefined) {367return null;368}369370const contentStr = typeof content === 'string' ? content : content.toString();371const model = this._modelService.createModel(contentStr, this._languageService.createByFilepathOrFirstLine(snapshotUri), snapshotUri, false);372373const store = new DisposableStore();374store.add(model.onWillDispose(() => store.dispose()));375store.add(this._timeline.onDidChangeContentsAtStop(requestId, snapshotUri, undoStop, c => model.setValue(c)));376377return model;378}379380public getSnapshotUri(requestId: string, uri: URI, stopId: string | undefined): URI | undefined {381return this._timeline.getContentURIAtStop(requestId, uri, stopId);382}383384public async restoreSnapshot(requestId: string, stopId: string | undefined): Promise<void> {385const checkpointId = this._timeline.getCheckpointIdForRequest(requestId, stopId);386if (checkpointId) {387await this._timeline.navigateToCheckpoint(checkpointId);388}389}390391private _assertNotDisposed(): void {392if (this._state.get() === ChatEditingSessionState.Disposed) {393throw new BugIndicatingError(`Cannot access a disposed editing session`);394}395}396397async accept(...uris: URI[]): Promise<void> {398if (await this._operateEntry('accept', uris)) {399this._accessibilitySignalService.playSignal(AccessibilitySignal.editsKept, { allowManyInParallel: true });400}401402}403404async reject(...uris: URI[]): Promise<void> {405if (await this._operateEntry('reject', uris)) {406this._accessibilitySignalService.playSignal(AccessibilitySignal.editsUndone, { allowManyInParallel: true });407}408}409410private async _operateEntry(action: 'accept' | 'reject', uris: URI[]): Promise<number> {411this._assertNotDisposed();412413const applicableEntries = this._entriesObs.get()414.filter(e => uris.length === 0 || uris.some(u => isEqual(u, e.modifiedURI)))415.filter(e => !e.isCurrentlyBeingModifiedBy.get())416.filter(e => e.state.get() === ModifiedFileEntryState.Modified);417418if (applicableEntries.length === 0) {419return 0;420}421422// Perform all I/O operations in parallel, each resolving to a state transition callback423const method = action === 'accept' ? 'acceptDeferred' : 'rejectDeferred';424const transitionCallbacks = await Promise.all(425applicableEntries.map(entry => entry[method]().catch(err => {426this._logService.error(`Error calling ${method} on entry ${entry.modifiedURI}`, err);427}))428);429430// Execute all state transitions atomically in a single transaction431transaction(tx => {432transitionCallbacks.forEach(callback => callback?.(tx));433});434435return applicableEntries.length;436}437438async show(previousChanges?: boolean): Promise<void> {439this._assertNotDisposed();440if (this._editorPane) {441if (this._editorPane.isVisible()) {442return;443} else if (this._editorPane.input) {444await this._editorGroupsService.activeGroup.openEditor(this._editorPane.input, { pinned: true, activation: EditorActivation.ACTIVATE });445return;446}447}448const input = MultiDiffEditorInput.fromResourceMultiDiffEditorInput({449multiDiffSource: getMultiDiffSourceUri(this, previousChanges),450label: localize('multiDiffEditorInput.name', "Suggested Edits")451}, this._instantiationService);452453this._editorPane = await this._editorGroupsService.activeGroup.openEditor(input, { pinned: true, activation: EditorActivation.ACTIVATE }) as MultiDiffEditor | undefined;454}455456private _stopPromise: Promise<void> | undefined;457458async stop(clearState = false): Promise<void> {459this._stopPromise ??= Promise.allSettled([this._performStop(), this.storeState()]).then(() => { });460await this._stopPromise;461if (clearState) {462await this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource).clearState();463}464}465466private async _performStop(): Promise<void> {467// Close out all open files468const schemes = [AbstractChatEditingModifiedFileEntry.scheme, ChatEditingTextModelContentProvider.scheme];469await Promise.allSettled(this._editorGroupsService.groups.flatMap(async (g) => {470return g.editors.map(async (e) => {471if ((e instanceof MultiDiffEditorInput && e.initialResources?.some(r => r.originalUri && schemes.indexOf(r.originalUri.scheme) !== -1))472|| (e instanceof DiffEditorInput && e.original.resource && schemes.indexOf(e.original.resource.scheme) !== -1)) {473await g.closeEditor(e);474}475});476}));477}478479override dispose() {480this._assertNotDisposed();481this.clearExplanations();482dispose(this._entriesObs.get());483super.dispose();484this._state.set(ChatEditingSessionState.Disposed, undefined);485this._onDidDispose.fire();486this._onDidDispose.dispose();487}488489private get isDisposed() {490return this._state.get() === ChatEditingSessionState.Disposed;491}492493startStreamingEdits(resource: URI, responseModel: IChatResponseModel, inUndoStop: string | undefined): IStreamingEdits {494const completePromise = new DeferredPromise<void>();495const startPromise = new DeferredPromise<void>();496497// Sequence all edits made this this resource in this streaming edits instance,498// and also sequence the resource overall in the rare (currently invalid?) case499// that edits are made in parallel to the same resource,500const sequencer = new ThrottledSequencer(15, 1000);501sequencer.queue(() => startPromise.p);502503// Lock around creating the baseline so we don't fail to resolve models504// in the edit pills if they render quickly505this._baselineCreationLocks.queue(resource.path, () => startPromise.p);506507this._streamingEditLocks.queue(resource.toString(), async () => {508await chatEditingSessionIsReady(this);509510if (!this.isDisposed) {511await this._acceptStreamingEditsStart(responseModel, inUndoStop, resource);512}513514startPromise.complete();515return completePromise.p;516});517518519let didComplete = false;520521return {522pushText: (edits, isLastEdits) => {523sequencer.queue(async () => {524if (!this.isDisposed) {525await this._acceptEdits(resource, edits, isLastEdits, responseModel);526}527});528},529pushNotebookCellText: (cell, edits, isLastEdits) => {530sequencer.queue(async () => {531if (!this.isDisposed) {532await this._acceptEdits(cell, edits, isLastEdits, responseModel);533}534});535},536pushNotebook: (edits, isLastEdits) => {537sequencer.queue(async () => {538if (!this.isDisposed) {539await this._acceptEdits(resource, edits, isLastEdits, responseModel);540}541});542},543complete: () => {544if (didComplete) {545return;546}547548didComplete = true;549sequencer.queue(async () => {550if (!this.isDisposed) {551await this._acceptEdits(resource, [], true, responseModel);552await this._resolve(responseModel.requestId, inUndoStop, resource);553completePromise.complete();554}555});556},557};558}559560startDeletion(resource: URI, responseModel: IChatResponseModel, undoStopId: string): void {561this._assertNotDisposed();562563// Queue the deletion operation with proper locking564this._streamingEditLocks.queue(resource.toString(), async () => {565if (this.isDisposed) {566return;567}568569await chatEditingSessionIsReady(this);570571// Check if file exists572let fileContent: string;573try {574const content = await this._fileService.readFile(resource);575fileContent = content.value.toString();576} catch (e) {577// File doesn't exist, nothing to delete578this._logService.warn(`Cannot delete file ${resource.toString()}: file does not exist`);579return;580}581582// Check if there's already an entry for this file583const existingEntry = this._getEntry(resource);584if (existingEntry) {585// If there's already an entry, we need to handle it differently586// For now, we'll just collapse it and proceed with deletion587existingEntry.dispose();588const entries = this._entriesObs.get().filter(e => e !== existingEntry);589this._entriesObs.set(entries, undefined);590}591592// Store initial content for timeline restoration593if (!this._initialFileContents.has(resource)) {594this._initialFileContents.set(resource, fileContent);595}596597// Delete the file on disk598await this._bulkEditService.apply({599edits: [{ oldResource: resource, options: { ignoreIfNotExists: true } }]600});601602// Record the delete operation in the timeline603this._timeline.recordFileOperation({604type: FileOperationType.Delete,605uri: resource,606requestId: responseModel.requestId,607epoch: this._timeline.incrementEpoch(),608finalContent: fileContent609});610611// Create a deleted file entry612const telemetryInfo = this._getTelemetryInfoForModel(responseModel);613const languageSelection = this._languageService.createByFilepathOrFirstLine(resource);614const entry = this._instantiationService.createInstance(615ChatEditingDeletedFileEntry,616resource,617fileContent,618{ collapse: (tx: ITransaction | undefined) => this._collapse(resource, tx) },619telemetryInfo,620languageSelection.languageId621);622623// Add entry to the entries observable624const entries = [...this._entriesObs.get(), entry];625this._entriesObs.set(entries, undefined);626});627}628629applyWorkspaceEdit(edit: IChatWorkspaceEdit, responseModel: IChatResponseModel, undoStopId: string): void {630for (const fileEdit of edit.edits) {631if (fileEdit.oldResource && !fileEdit.newResource) {632// File deletion633this.startDeletion(fileEdit.oldResource, responseModel, undoStopId);634}635// Future: handle file creations and renames636}637}638639async startExternalEdits(responseModel: IChatResponseModel, operationId: number, resources: URI[], undoStopId: string): Promise<IChatProgress[]> {640const snapshots = new ResourceMap<string | undefined>();641const acquiredLockPromises: DeferredPromise<void>[] = [];642const releaseLockPromises: DeferredPromise<void>[] = [];643const progress: IChatProgress[] = [];644const telemetryInfo = this._getTelemetryInfoForModel(responseModel);645646await chatEditingSessionIsReady(this);647648// Acquire locks for each resource and take snapshots649for (const resource of resources) {650const releaseLock = new DeferredPromise<void>();651releaseLockPromises.push(releaseLock);652653const acquiredLock = new DeferredPromise<void>();654acquiredLockPromises.push(acquiredLock);655656this._streamingEditLocks.queue(resource.toString(), async () => {657if (this.isDisposed) {658acquiredLock.complete();659return;660}661662const entry = await this._getOrCreateModifiedFileEntry(resource, NotExistBehavior.Abort, telemetryInfo);663if (entry) {664await this._acceptStreamingEditsStart(responseModel, undoStopId, resource);665}666667668const notebookUri = CellUri.parse(resource)?.notebook || resource;669progress.push(...createOpeningEditCodeBlock(resource, this._notebookService.hasSupportedNotebooks(notebookUri), undoStopId));670671// Save to disk to ensure disk state is current before external edits672await entry?.save();673674// Take snapshot of current state675snapshots.set(resource, entry && this._getCurrentTextOrNotebookSnapshot(entry));676entry?.startExternalEdit();677acquiredLock.complete();678679// Wait for the lock to be released by stopExternalEdits680return releaseLock.p;681});682}683684await Promise.all(acquiredLockPromises.map(p => p.p));685this.createSnapshot(responseModel.requestId, undoStopId);686687// Store the operation state688this._externalEditOperations.set(operationId, {689responseModel,690snapshots,691undoStopId,692releaseLocks: () => releaseLockPromises.forEach(p => p.complete())693});694695return progress;696}697698async stopExternalEdits(responseModel: IChatResponseModel, operationId: number): Promise<IChatProgress[]> {699const operation = this._externalEditOperations.get(operationId);700if (!operation) {701this._logService.warn(`stopExternalEdits called for unknown operation ${operationId}`);702return [];703}704705this._externalEditOperations.delete(operationId);706707const progress: IChatProgress[] = [];708709try {710// For each resource, compute the diff and create edit parts711for (const [resource, beforeSnapshot] of operation.snapshots) {712let entry = this._getEntry(resource);713714// Files that did not exist on disk before may not exist in our working715// set yet. Create those if that's the case.716if (!entry && beforeSnapshot === undefined) {717entry = await this._getOrCreateModifiedFileEntry(resource, NotExistBehavior.Abort, this._getTelemetryInfoForModel(responseModel), '');718if (entry) {719entry.startExternalEdit();720entry.acceptStreamingEditsStart(responseModel, operation.undoStopId, undefined);721}722}723724if (!entry) {725continue;726}727728// Reload from disk to ensure in-memory model is in sync with file system729await entry.revertToDisk();730731// Take new snapshot after external changes732const afterSnapshot = this._getCurrentTextOrNotebookSnapshot(entry);733734// Compute edits from the snapshots735let edits: (TextEdit | ICellEditOperation)[] = [];736if (beforeSnapshot === undefined) {737this._timeline.recordFileOperation({738type: FileOperationType.Create,739uri: resource,740requestId: responseModel.requestId,741epoch: this._timeline.incrementEpoch(),742initialContent: afterSnapshot,743telemetryInfo: entry.telemetryInfo,744});745} else {746edits = await entry.computeEditsFromSnapshots(beforeSnapshot, afterSnapshot);747this._recordEditOperations(entry, resource, edits, responseModel);748}749750progress.push(entry instanceof ChatEditingModifiedNotebookEntry ? {751kind: 'notebookEdit',752uri: resource,753edits: edits as ICellEditOperation[],754done: true,755isExternalEdit: true756} : {757kind: 'textEdit',758uri: resource,759edits: edits as TextEdit[],760done: true,761isExternalEdit: true762});763764// Mark as no longer being modified765await entry.acceptStreamingEditsEnd();766767// Accept the changes for background sessions768if (getChatSessionType(this.chatSessionResource) === AgentSessionProviders.Background) {769await entry.accept();770}771772// Clear external edit mode773entry.stopExternalEdit();774}775} finally {776// Release all the locks777operation.releaseLocks();778779const hasOtherTasks = Iterable.some(this._streamingEditLocks.keys(), k => !operation.snapshots.has(URI.parse(k)));780if (!hasOtherTasks) {781this._state.set(ChatEditingSessionState.Idle, undefined);782}783}784785786return progress;787}788789async undoInteraction(): Promise<void> {790await this._timeline.undoToLastCheckpoint();791}792793async redoInteraction(): Promise<void> {794await this._timeline.redoToNextCheckpoint();795}796797async triggerExplanationGeneration(): Promise<void> {798// Clear any existing explanations first799this.clearExplanations();800801const entries = this._entriesObs.get();802const diffInfos: IExplanationDiffInfo[] = [];803for (const entry of entries) {804if (entry instanceof ChatEditingModifiedDocumentEntry) {805const diff = await entry.getDiffInfo();806diffInfos.push({807changes: diff.changes,808identical: diff.identical,809originalModel: entry.originalModel,810modifiedModel: entry.modifiedModel,811});812}813}814815if (diffInfos.length > 0) {816this._explanationHandle = this._explanationModelManager.generateExplanations(diffInfos, this.chatSessionResource, CancellationToken.None);817await this._explanationHandle.completed;818}819}820821clearExplanations(): void {822if (this._explanationHandle) {823this._explanationHandle.dispose();824this._explanationHandle = undefined;825}826}827828hasExplanations(): boolean {829return this._explanationHandle !== undefined;830}831832private _recordEditOperations(entry: AbstractChatEditingModifiedFileEntry, resource: URI, edits: (TextEdit | ICellEditOperation)[], responseModel: IChatResponseModel): void {833// Determine if these are text edits or notebook edits834const isNotebookEdits = edits.length > 0 && hasKey(edits[0], { cells: true });835836if (isNotebookEdits) {837// Record notebook edit operation838const notebookEdits = edits as ICellEditOperation[];839this._timeline.recordFileOperation({840type: FileOperationType.NotebookEdit,841uri: resource,842requestId: responseModel.requestId,843epoch: this._timeline.incrementEpoch(),844cellEdits: notebookEdits845});846} else {847let cellIndex: number | undefined;848if (entry instanceof ChatEditingModifiedNotebookEntry) {849const cellUri = CellUri.parse(resource);850if (cellUri) {851const i = entry.getIndexOfCellHandle(cellUri.handle);852if (i !== -1) {853cellIndex = i;854}855}856}857858const textEdits = edits as TextEdit[];859this._timeline.recordFileOperation({860type: FileOperationType.TextEdit,861uri: resource,862requestId: responseModel.requestId,863epoch: this._timeline.incrementEpoch(),864edits: textEdits,865cellIndex,866});867}868}869870private _getCurrentTextOrNotebookSnapshot(entry: AbstractChatEditingModifiedFileEntry): string {871if (entry instanceof ChatEditingModifiedNotebookEntry) {872return entry.getCurrentSnapshot();873} else if (entry instanceof ChatEditingModifiedDocumentEntry) {874return entry.getCurrentContents();875} else if (entry instanceof ChatEditingDeletedFileEntry) {876return '';877} else {878throw new Error(`unknown entry type for ${entry.modifiedURI}`);879}880}881882private async _acceptStreamingEditsStart(responseModel: IChatResponseModel, undoStop: string | undefined, resource: URI) {883const entry = await this._getOrCreateModifiedFileEntry(resource, NotExistBehavior.Create, this._getTelemetryInfoForModel(responseModel));884885// Record file baseline if this is the first edit for this file in this request886if (!this._timeline.hasFileBaseline(resource, responseModel.requestId)) {887this._timeline.recordFileBaseline({888uri: resource,889requestId: responseModel.requestId,890content: this._getCurrentTextOrNotebookSnapshot(entry),891epoch: this._timeline.incrementEpoch(),892telemetryInfo: entry.telemetryInfo,893notebookViewType: entry instanceof ChatEditingModifiedNotebookEntry ? entry.viewType : undefined,894});895}896897transaction((tx) => {898this._state.set(ChatEditingSessionState.StreamingEdits, tx);899entry.acceptStreamingEditsStart(responseModel, undoStop, tx);900// Note: Individual edit operations will be recorded by the file entries901});902903return entry;904}905906private async _initEntries({ entries }: IChatEditingSessionStop): Promise<void> {907// Reset all the files which are modified in this session state908// but which are not found in the snapshot909for (const entry of this._entriesObs.get()) {910const snapshotEntry = entries.get(entry.modifiedURI);911if (!snapshotEntry) {912await entry.resetToInitialContent();913entry.dispose();914}915}916917const entriesArr: AbstractChatEditingModifiedFileEntry[] = [];918// Restore all entries from the snapshot919for (const snapshotEntry of entries.values()) {920let entry: AbstractChatEditingModifiedFileEntry | undefined;921922if (snapshotEntry.isDeleted) {923// Create a deleted file entry924entry = this._instantiationService.createInstance(925ChatEditingDeletedFileEntry,926snapshotEntry.resource,927snapshotEntry.original, // original content before deletion928{ collapse: (tx: ITransaction | undefined) => this._collapse(snapshotEntry.resource, tx) },929snapshotEntry.telemetryInfo,930snapshotEntry.languageId931);932await entry.restoreFromSnapshot(snapshotEntry, false);933} else {934entry = await this._getOrCreateModifiedFileEntry(snapshotEntry.resource, NotExistBehavior.Abort, snapshotEntry.telemetryInfo);935if (entry) {936const restoreToDisk = snapshotEntry.state === ModifiedFileEntryState.Modified;937await entry.restoreFromSnapshot(snapshotEntry, restoreToDisk);938}939}940941if (entry) {942entriesArr.push(entry);943}944}945946this._entriesObs.set(entriesArr, undefined);947}948949private async _acceptEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise<void> {950const entry = await this._getOrCreateModifiedFileEntry(resource, NotExistBehavior.Create, this._getTelemetryInfoForModel(responseModel));951952// Record edit operations in the timeline if there are actual edits953if (textEdits.length > 0) {954this._recordEditOperations(entry, resource, textEdits, responseModel);955}956957await entry.acceptAgentEdits(resource, textEdits, isLastEdits, responseModel);958}959960private _getTelemetryInfoForModel(responseModel: IChatResponseModel): IModifiedEntryTelemetryInfo {961// Make these getters because the response result is not available when the file first starts to be edited962return new class implements IModifiedEntryTelemetryInfo {963get agentId() { return responseModel.agent?.id; }964get modelId() { return responseModel.request?.modelId; }965get modeId() { return responseModel.request?.modeInfo?.modeId; }966get command() { return responseModel.slashCommand?.name; }967get sessionResource() { return responseModel.session.sessionResource; }968get requestId() { return responseModel.requestId; }969get result() { return responseModel.result; }970get applyCodeBlockSuggestionId() { return responseModel.request?.modeInfo?.applyCodeBlockSuggestionId; }971972get feature(): 'sideBarChat' | 'inlineChat' | undefined {973if (responseModel.session.initialLocation === ChatAgentLocation.Chat) {974return 'sideBarChat';975} else if (responseModel.session.initialLocation === ChatAgentLocation.EditorInline) {976return 'inlineChat';977}978return undefined;979}980};981}982983private async _resolve(requestId: string, undoStop: string | undefined, resource: URI): Promise<void> {984const hasOtherTasks = Iterable.some(this._streamingEditLocks.keys(), k => k !== resource.toString());985if (!hasOtherTasks) {986this._state.set(ChatEditingSessionState.Idle, undefined);987}988989const entry = this._getEntry(resource);990if (!entry) {991return;992}993994// Create checkpoint for this edit completion995const label = undoStop ? `Request ${requestId} - Stop ${undoStop}` : `Request ${requestId}`;996this._timeline.createCheckpoint(requestId, undoStop, label);997998return entry.acceptStreamingEditsEnd();999}10001001/**1002* Retrieves or creates a modified file entry.1003*1004* @returns The modified file entry.1005*/1006private async _getOrCreateModifiedFileEntry(resource: URI, ifNotExists: NotExistBehavior.Create, telemetryInfo: IModifiedEntryTelemetryInfo, initialContent?: string): Promise<AbstractChatEditingModifiedFileEntry>;1007private async _getOrCreateModifiedFileEntry(resource: URI, ifNotExists: NotExistBehavior, telemetryInfo: IModifiedEntryTelemetryInfo, initialContent?: string): Promise<AbstractChatEditingModifiedFileEntry | undefined>;1008private async _getOrCreateModifiedFileEntry(resource: URI, ifNotExists: NotExistBehavior, telemetryInfo: IModifiedEntryTelemetryInfo, _initialContent?: string): Promise<AbstractChatEditingModifiedFileEntry | undefined> {10091010resource = CellUri.parse(resource)?.notebook ?? resource;10111012const existingEntry = this._entriesObs.get().find(e => isEqual(e.modifiedURI, resource));1013if (existingEntry) {1014// If the existing entry is a deleted file entry, we need to replace it with a new modified entry1015// This handles the case where a file was deleted and then recreated1016if (existingEntry instanceof ChatEditingDeletedFileEntry) {1017// Use the original content from the deleted entry as the initial content for the new entry1018const initialContentFromDeleted = existingEntry.state.get() === ModifiedFileEntryState.Modified1019? existingEntry.initialContent1020: undefined;10211022// Remove the deleted entry1023existingEntry.dispose();1024const entries = this._entriesObs.get().filter(e => e !== existingEntry);1025this._entriesObs.set(entries, undefined);10261027// Set the initial content from the deleted entry if it was still in modified state1028if (initialContentFromDeleted !== undefined) {1029_initialContent = initialContentFromDeleted;1030}1031// Fall through to create a new entry1032} else {1033if (telemetryInfo.requestId !== existingEntry.telemetryInfo.requestId) {1034existingEntry.updateTelemetryInfo(telemetryInfo);1035}1036return existingEntry;1037}1038}10391040let entry: AbstractChatEditingModifiedFileEntry;1041const existingExternalEntry = this._lookupExternalEntry(resource);1042if (existingExternalEntry) {1043entry = existingExternalEntry;10441045if (telemetryInfo.requestId !== entry.telemetryInfo.requestId) {1046entry.updateTelemetryInfo(telemetryInfo);1047}1048} else {1049const initialContent = _initialContent ?? this._initialFileContents.get(resource);1050// This gets manually disposed in .dispose() or in .restoreSnapshot()1051const maybeEntry = await this._createModifiedFileEntry(resource, telemetryInfo, ifNotExists, initialContent);1052if (!maybeEntry) {1053return undefined;1054}1055entry = maybeEntry;1056if (initialContent === undefined) {1057this._initialFileContents.set(resource, entry.initialContent);1058}1059}10601061// If an entry is deleted e.g. reverting a created file,1062// remove it from the entries and don't show it in the working set anymore1063// so that it can be recreated e.g. through retry1064const listener = entry.onDidDelete(() => {1065const newEntries = this._entriesObs.get().filter(e => !isEqual(e.modifiedURI, entry.modifiedURI));1066this._entriesObs.set(newEntries, undefined);1067this._editorService.closeEditors(this._editorService.findEditors(entry.modifiedURI));10681069if (!existingExternalEntry) {1070// don't dispose entries that are not yours!1071entry.dispose();1072}10731074this._store.delete(listener);1075});1076this._store.add(listener);10771078const entriesArr = [...this._entriesObs.get(), entry];1079this._entriesObs.set(entriesArr, undefined);10801081return entry;1082}10831084private async _createModifiedFileEntry(resource: URI, telemetryInfo: IModifiedEntryTelemetryInfo, ifNotExists: NotExistBehavior.Create, initialContent: string | undefined): Promise<AbstractChatEditingModifiedFileEntry>;1085private async _createModifiedFileEntry(resource: URI, telemetryInfo: IModifiedEntryTelemetryInfo, ifNotExists: NotExistBehavior, initialContent: string | undefined): Promise<AbstractChatEditingModifiedFileEntry | undefined>;10861087private async _createModifiedFileEntry(resource: URI, telemetryInfo: IModifiedEntryTelemetryInfo, ifNotExists: NotExistBehavior, initialContent: string | undefined): Promise<AbstractChatEditingModifiedFileEntry | undefined> {1088const multiDiffEntryDelegate = {1089collapse: (transaction: ITransaction | undefined) => this._collapse(resource, transaction),1090recordOperation: (operation: Mutable<FileOperation>) => {1091operation.epoch = this._timeline.incrementEpoch();1092this._timeline.recordFileOperation(operation);1093},1094};1095const notebookUri = CellUri.parse(resource)?.notebook || resource;1096const doCreate = async (chatKind: ChatEditKind) => {1097if (this._notebookService.hasSupportedNotebooks(notebookUri)) {1098return await ChatEditingModifiedNotebookEntry.create(notebookUri, multiDiffEntryDelegate, telemetryInfo, chatKind, initialContent, this._instantiationService);1099} else {1100const ref = await this._textModelService.createModelReference(resource);1101return this._instantiationService.createInstance(ChatEditingModifiedDocumentEntry, ref, multiDiffEntryDelegate, telemetryInfo, chatKind, initialContent);1102}1103};11041105try {1106return await doCreate(ChatEditKind.Modified);1107} catch (err) {1108if (ifNotExists === NotExistBehavior.Abort) {1109return undefined;1110}11111112// this file does not exist yet, create it and try again1113await this._bulkEditService.apply({ edits: [{ newResource: resource }] });1114if (this.configurationService.getValue<boolean>('accessibility.openChatEditedFiles')) {1115this._editorService.openEditor({ resource, options: { inactive: true, preserveFocus: true, pinned: true } });1116}11171118// Record file creation operation1119this._timeline.recordFileOperation({1120type: FileOperationType.Create,1121uri: resource,1122requestId: telemetryInfo.requestId,1123epoch: this._timeline.incrementEpoch(),1124initialContent: initialContent || '',1125telemetryInfo,1126});11271128if (this._notebookService.hasSupportedNotebooks(notebookUri)) {1129return await ChatEditingModifiedNotebookEntry.create(resource, multiDiffEntryDelegate, telemetryInfo, ChatEditKind.Created, initialContent, this._instantiationService);1130} else {1131return await doCreate(ChatEditKind.Created);1132}1133}1134}11351136private _collapse(resource: URI, transaction: ITransaction | undefined) {1137const multiDiffItem = this._editorPane?.findDocumentDiffItem(resource);1138if (multiDiffItem) {1139this._editorPane?.viewModel?.items.get().find((documentDiffItem) =>1140isEqual(documentDiffItem.originalUri, multiDiffItem.originalUri) &&1141isEqual(documentDiffItem.modifiedUri, multiDiffItem.modifiedUri))1142?.collapsed.set(true, transaction);1143}1144}1145}114611471148