Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.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*--------------------------------------------------------------------------------------------*/45import { streamToBuffer } from '../../../../../base/common/buffer.js';6import { CancellationToken } from '../../../../../base/common/cancellation.js';7import { StringSHA1 } from '../../../../../base/common/hash.js';8import { DisposableStore, IReference, thenRegisterOrDispose } from '../../../../../base/common/lifecycle.js';9import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js';10import { Schemas } from '../../../../../base/common/network.js';11import { ITransaction, IObservable, observableValue, autorun, transaction, ObservablePromise } from '../../../../../base/common/observable.js';12import { isEqual } from '../../../../../base/common/resources.js';13import { assertType } from '../../../../../base/common/types.js';14import { URI } from '../../../../../base/common/uri.js';15import { generateUuid } from '../../../../../base/common/uuid.js';16import { LineRange } from '../../../../../editor/common/core/ranges/lineRange.js';17import { Range } from '../../../../../editor/common/core/range.js';18import { nullDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js';19import { DetailedLineRangeMapping, RangeMapping } from '../../../../../editor/common/diff/rangeMapping.js';20import { TextEdit } from '../../../../../editor/common/languages.js';21import { ITextModel } from '../../../../../editor/common/model.js';22import { IModelService } from '../../../../../editor/common/services/model.js';23import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';24import { localize } from '../../../../../nls.js';25import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';26import { IFileService } from '../../../../../platform/files/common/files.js';27import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';28import { IUndoRedoElement, IUndoRedoService, UndoRedoElementType } from '../../../../../platform/undoRedo/common/undoRedo.js';29import { IEditorPane, SaveReason } from '../../../../common/editor.js';30import { IFilesConfigurationService } from '../../../../services/filesConfiguration/common/filesConfigurationService.js';31import { SnapshotContext } from '../../../../services/workingCopy/common/fileWorkingCopy.js';32import { NotebookTextDiffEditor } from '../../../notebook/browser/diff/notebookDiffEditor.js';33import { INotebookTextDiffEditor } from '../../../notebook/browser/diff/notebookDiffEditorBrowser.js';34import { CellDiffInfo } from '../../../notebook/browser/diff/notebookDiffViewModel.js';35import { getNotebookEditorFromEditorPane } from '../../../notebook/browser/notebookBrowser.js';36import { NotebookCellTextModel } from '../../../notebook/common/model/notebookCellTextModel.js';37import { NotebookTextModel } from '../../../notebook/common/model/notebookTextModel.js';38import { CellEditType, ICellDto2, ICellEditOperation, ICellReplaceEdit, IResolvedNotebookEditorModel, NotebookCellsChangeType, NotebookSetting, NotebookTextModelChangedEvent, TransientOptions } from '../../../notebook/common/notebookCommon.js';39import { computeDiff } from '../../../notebook/common/notebookDiff.js';40import { INotebookEditorModelResolverService } from '../../../notebook/common/notebookEditorModelResolverService.js';41import { INotebookLoggingService } from '../../../notebook/common/notebookLoggingService.js';42import { INotebookService } from '../../../notebook/common/notebookService.js';43import { INotebookEditorWorkerService } from '../../../notebook/common/services/notebookWorkerService.js';44import { ChatEditKind, IModifiedEntryTelemetryInfo, IModifiedFileEntryEditorIntegration, ISnapshotEntry, ModifiedFileEntryState } from '../../common/chatEditingService.js';45import { IChatResponseModel } from '../../common/chatModel.js';46import { IChatService } from '../../common/chatService.js';47import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js';48import { createSnapshot, deserializeSnapshot, getNotebookSnapshotFileURI, restoreSnapshot, SnapshotComparer } from './notebook/chatEditingModifiedNotebookSnapshot.js';49import { ChatEditingNewNotebookContentEdits } from './notebook/chatEditingNewNotebookContentEdits.js';50import { ChatEditingNotebookCellEntry } from './notebook/chatEditingNotebookCellEntry.js';51import { ChatEditingNotebookDiffEditorIntegration, ChatEditingNotebookEditorIntegration } from './notebook/chatEditingNotebookEditorIntegration.js';52import { ChatEditingNotebookFileSystemProvider } from './notebook/chatEditingNotebookFileSystemProvider.js';53import { adjustCellDiffAndOriginalModelBasedOnCellAddDelete, adjustCellDiffAndOriginalModelBasedOnCellMovements, adjustCellDiffForKeepingAnInsertedCell, adjustCellDiffForRevertingADeletedCell, adjustCellDiffForRevertingAnInsertedCell, calculateNotebookRewriteRatio, getCorrespondingOriginalCellIndex, isTransientIPyNbExtensionEvent } from './notebook/helpers.js';54import { countChanges, ICellDiffInfo, sortCellChanges } from './notebook/notebookCellChanges.js';55import { IAiEditTelemetryService } from '../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js';565758const SnapshotLanguageId = 'VSCodeChatNotebookSnapshotLanguage';5960export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifiedFileEntry {61static NewModelCounter: number = 0;62private readonly modifiedModel: NotebookTextModel;63private readonly originalModel: NotebookTextModel;64override originalURI: URI;65/**66* JSON stringified version of the original notebook.67*/68override initialContent: string;69/**70* Whether we're still generating diffs from a response.71*/72private _isProcessingResponse = observableValue<boolean>('isProcessingResponse', false);73get isProcessingResponse(): IObservable<boolean> {74return this._isProcessingResponse;75}76private _isEditFromUs: boolean = false;77/**78* Whether all edits are from us, e.g. is possible a user has made edits, then this will be false.79*/80private _allEditsAreFromUs: boolean = true;81private readonly _changesCount = observableValue<number>(this, 0);82override changesCount: IObservable<number> = this._changesCount;8384private readonly cellEntryMap = new ResourceMap<ChatEditingNotebookCellEntry>();85private modifiedToOriginalCell = new ResourceMap<URI>();86private readonly _cellsDiffInfo = observableValue<ICellDiffInfo[]>('diffInfo', []);8788get cellsDiffInfo(): IObservable<ICellDiffInfo[]> {89return this._cellsDiffInfo;90}9192/**93* List of Cell URIs that are edited,94* Will be cleared once all edits have been accepted.95* I.e. this will only contain URIS while acceptAgentEdits is being called & before `isLastEdit` is sent.96* I.e. this is populated only when edits are being streamed.97*/98private readonly editedCells = new ResourceSet();99100public static async create(uri: URI, _multiDiffEntryDelegate: { collapse: (transaction: ITransaction | undefined) => void }, telemetryInfo: IModifiedEntryTelemetryInfo, chatKind: ChatEditKind, initialContent: string | undefined, instantiationService: IInstantiationService): Promise<AbstractChatEditingModifiedFileEntry> {101return instantiationService.invokeFunction(async accessor => {102const notebookService = accessor.get(INotebookService);103const resolver = accessor.get(INotebookEditorModelResolverService);104const configurationServie = accessor.get(IConfigurationService);105const resourceRef: IReference<IResolvedNotebookEditorModel> = await resolver.resolve(uri);106const notebook = resourceRef.object.notebook;107const originalUri = getNotebookSnapshotFileURI(telemetryInfo.sessionId, telemetryInfo.requestId, generateUuid(), notebook.uri.scheme === Schemas.untitled ? `/${notebook.uri.path}` : notebook.uri.path, notebook.viewType);108const [options, buffer] = await Promise.all([109notebookService.withNotebookDataProvider(resourceRef.object.notebook.notebookType),110notebookService.createNotebookTextDocumentSnapshot(notebook.uri, SnapshotContext.Backup, CancellationToken.None).then(s => streamToBuffer(s))111]);112const disposables = new DisposableStore();113// Register so that we can load this from file system.114disposables.add(ChatEditingNotebookFileSystemProvider.registerFile(originalUri, buffer));115const originalRef = await resolver.resolve(originalUri, notebook.viewType);116if (initialContent) {117try {118restoreSnapshot(originalRef.object.notebook, initialContent);119} catch (ex) {120console.error(`Error restoring snapshot: ${initialContent}`, ex);121initialContent = createSnapshot(notebook, options.serializer.options, configurationServie);122}123} else {124initialContent = createSnapshot(notebook, options.serializer.options, configurationServie);125// Both models are the same, ensure the cell ids are the same, this way we get a perfect diffing.126// No need to generate edits for this.127// We want to ensure they are identitcal, possible original notebook was open and got modified.128// Or something gets changed between serialization & deserialization of the snapshot into the original.129// E.g. in jupyter notebooks the metadata contains transient data that gets updated after deserialization.130restoreSnapshot(originalRef.object.notebook, initialContent);131const edits: ICellEditOperation[] = [];132notebook.cells.forEach((cell, index) => {133const internalId = generateCellHash(cell.uri);134edits.push({ editType: CellEditType.PartialInternalMetadata, index, internalMetadata: { internalId } });135});136resourceRef.object.notebook.applyEdits(edits, true, undefined, () => undefined, undefined, false);137originalRef.object.notebook.applyEdits(edits, true, undefined, () => undefined, undefined, false);138}139const instance = instantiationService.createInstance(ChatEditingModifiedNotebookEntry, resourceRef, originalRef, _multiDiffEntryDelegate, options.serializer.options, telemetryInfo, chatKind, initialContent);140instance._register(disposables);141return instance;142});143}144145public static canHandleSnapshotContent(initialContent: string | undefined): boolean {146if (!initialContent) {147return false;148}149150try {151deserializeSnapshot(initialContent);152return true;153} catch (ex) {154// not a valid snapshot155return false;156}157}158159public static canHandleSnapshot(snapshot: ISnapshotEntry): boolean {160if (snapshot.languageId === SnapshotLanguageId && ChatEditingModifiedNotebookEntry.canHandleSnapshotContent(snapshot.current)) {161return true;162}163return false;164}165166private readonly initialContentComparer: SnapshotComparer;167168constructor(169private readonly modifiedResourceRef: IReference<IResolvedNotebookEditorModel>,170originalResourceRef: IReference<IResolvedNotebookEditorModel>,171private readonly _multiDiffEntryDelegate: { collapse: (transaction: ITransaction | undefined) => void },172private readonly transientOptions: TransientOptions | undefined,173telemetryInfo: IModifiedEntryTelemetryInfo,174kind: ChatEditKind,175initialContent: string,176@IConfigurationService private readonly configurationService: IConfigurationService,177@IFilesConfigurationService fileConfigService: IFilesConfigurationService,178@IChatService chatService: IChatService,179@IFileService fileService: IFileService,180@IInstantiationService instantiationService: IInstantiationService,181@ITextModelService private readonly textModelService: ITextModelService,182@IModelService private readonly modelService: IModelService,183@IUndoRedoService undoRedoService: IUndoRedoService,184@INotebookEditorWorkerService private readonly notebookEditorWorkerService: INotebookEditorWorkerService,185@INotebookLoggingService private readonly loggingService: INotebookLoggingService,186@INotebookEditorModelResolverService private readonly notebookResolver: INotebookEditorModelResolverService,187@IAiEditTelemetryService aiEditTelemetryService: IAiEditTelemetryService,188) {189super(modifiedResourceRef.object.notebook.uri, telemetryInfo, kind, configurationService, fileConfigService, chatService, fileService, undoRedoService, instantiationService, aiEditTelemetryService);190this.initialContentComparer = new SnapshotComparer(initialContent);191this.modifiedModel = this._register(modifiedResourceRef).object.notebook;192this.originalModel = this._register(originalResourceRef).object.notebook;193this.originalURI = this.originalModel.uri;194this.initialContent = initialContent;195this.initializeModelsFromDiff();196this._register(this.modifiedModel.onDidChangeContent(this.mirrorNotebookEdits, this));197}198199initializeModelsFromDiffImpl(cellsDiffInfo: CellDiffInfo[]) {200this.cellEntryMap.forEach(entry => entry.dispose());201this.cellEntryMap.clear();202const diffs = cellsDiffInfo.map((cellDiff, i) => {203switch (cellDiff.type) {204case 'delete':205return this.createDeleteCellDiffInfo(cellDiff.originalCellIndex);206case 'insert':207return this.createInsertedCellDiffInfo(cellDiff.modifiedCellIndex);208default:209return this.createModifiedCellDiffInfo(cellDiff.modifiedCellIndex, cellDiff.originalCellIndex);210}211});212this._cellsDiffInfo.set(diffs, undefined);213this._changesCount.set(countChanges(diffs), undefined);214}215216private computeRequestId: number = 0;217async initializeModelsFromDiff() {218const id = ++this.computeRequestId;219if (this._areOriginalAndModifiedIdenticalImpl()) {220const cellsDiffInfo: CellDiffInfo[] = this.modifiedModel.cells.map((_, index) => {221return { type: 'unchanged', originalCellIndex: index, modifiedCellIndex: index } satisfies CellDiffInfo;222});223this.initializeModelsFromDiffImpl(cellsDiffInfo);224return;225}226const cellsDiffInfo: CellDiffInfo[] = [];227try {228this._isProcessingResponse.set(true, undefined);229const notebookDiff = await this.notebookEditorWorkerService.computeDiff(this.originalURI, this.modifiedURI);230if (id !== this.computeRequestId || this._store.isDisposed) {231return;232}233const result = computeDiff(this.originalModel, this.modifiedModel, notebookDiff);234if (result.cellDiffInfo.length) {235cellsDiffInfo.push(...result.cellDiffInfo);236}237} catch (ex) {238this.loggingService.error('Notebook Chat', 'Error computing diff:\n' + ex);239} finally {240this._isProcessingResponse.set(false, undefined);241}242this.initializeModelsFromDiffImpl(cellsDiffInfo);243}244updateCellDiffInfo(cellsDiffInfo: ICellDiffInfo[], transcation: ITransaction | undefined) {245this._cellsDiffInfo.set(sortCellChanges(cellsDiffInfo), transcation);246this._changesCount.set(countChanges(cellsDiffInfo), transcation);247248}249250mirrorNotebookEdits(e: NotebookTextModelChangedEvent) {251if (this._isEditFromUs || Array.from(this.cellEntryMap.values()).some(entry => entry.isEditFromUs)) {252return;253}254255// Possible user reverted the changes from SCM or the like.256// Or user just reverted the changes made via edits (e.g. edit made a change in a cell and user undid that change either by typing over or other).257// Computing snapshot is too slow, as this event gets triggered for every key stroke in a cell,258// const didResetToOriginalContent = createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService) === this.initialContent;259let didResetToOriginalContent = this.initialContentComparer.isEqual(this.modifiedModel);260const currentState = this._stateObs.get();261if (currentState === ModifiedFileEntryState.Modified && didResetToOriginalContent) {262this._stateObs.set(ModifiedFileEntryState.Rejected, undefined);263this.updateCellDiffInfo([], undefined);264this.initializeModelsFromDiff();265this._notifySessionAction('rejected');266return;267}268269if (!e.rawEvents.length) {270return;271}272273if (currentState === ModifiedFileEntryState.Rejected) {274return;275}276277if (isTransientIPyNbExtensionEvent(this.modifiedModel.notebookType, e)) {278return;279}280281this._allEditsAreFromUs = false;282this._userEditScheduler.schedule();283284// Changes to cell text is sync'ed and handled separately.285// See ChatEditingNotebookCellEntry._mirrorEdits286for (const event of e.rawEvents.filter(event => event.kind !== NotebookCellsChangeType.ChangeCellContent)) {287switch (event.kind) {288case NotebookCellsChangeType.ChangeDocumentMetadata: {289const edit: ICellEditOperation = {290editType: CellEditType.DocumentMetadata,291metadata: this.modifiedModel.metadata292};293this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false);294break;295}296case NotebookCellsChangeType.ModelChange: {297let cellDiffs = sortCellChanges(this._cellsDiffInfo.get());298// Ensure the new notebook cells have internalIds299this._applyEditsSync(() => {300event.changes.forEach(change => {301change[2].forEach((cell, i) => {302if (cell.internalMetadata.internalId) {303return;304}305const index = change[0] + i;306const internalId = generateCellHash(cell.uri);307const edits: ICellEditOperation[] = [{ editType: CellEditType.PartialInternalMetadata, index, internalMetadata: { internalId } }];308this.modifiedModel.applyEdits(edits, true, undefined, () => undefined, undefined, false);309cell.internalMetadata ??= {};310cell.internalMetadata.internalId = internalId;311});312});313});314event.changes.forEach(change => {315cellDiffs = adjustCellDiffAndOriginalModelBasedOnCellAddDelete(change,316cellDiffs,317this.modifiedModel.cells.length,318this.originalModel.cells.length,319this.originalModel.applyEdits.bind(this.originalModel),320this.createModifiedCellDiffInfo.bind(this));321});322this.updateCellDiffInfo(cellDiffs, undefined);323this.disposeDeletedCellEntries();324break;325}326case NotebookCellsChangeType.ChangeCellLanguage: {327const index = getCorrespondingOriginalCellIndex(event.index, this._cellsDiffInfo.get());328if (typeof index === 'number') {329const edit: ICellEditOperation = {330editType: CellEditType.CellLanguage,331index,332language: event.language333};334this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false);335}336break;337}338case NotebookCellsChangeType.ChangeCellMetadata: {339// ipynb and other extensions can alter metadata, ensure we update the original model in the corresponding cell.340const index = getCorrespondingOriginalCellIndex(event.index, this._cellsDiffInfo.get());341if (typeof index === 'number') {342const edit: ICellEditOperation = {343editType: CellEditType.Metadata,344index,345metadata: event.metadata346};347this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false);348}349break;350}351case NotebookCellsChangeType.ChangeCellMime:352break;353case NotebookCellsChangeType.ChangeCellInternalMetadata: {354const index = getCorrespondingOriginalCellIndex(event.index, this._cellsDiffInfo.get());355if (typeof index === 'number') {356const edit: ICellEditOperation = {357editType: CellEditType.PartialInternalMetadata,358index,359internalMetadata: event.internalMetadata360};361this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false);362}363break;364}365case NotebookCellsChangeType.Output: {366// User can run cells.367const index = getCorrespondingOriginalCellIndex(event.index, this._cellsDiffInfo.get());368if (typeof index === 'number') {369const edit: ICellEditOperation = {370editType: CellEditType.Output,371index,372append: event.append,373outputs: event.outputs374};375this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false);376}377break;378}379case NotebookCellsChangeType.OutputItem: {380const index = getCorrespondingOriginalCellIndex(event.index, this._cellsDiffInfo.get());381if (typeof index === 'number') {382const edit: ICellEditOperation = {383editType: CellEditType.OutputItems,384outputId: event.outputId,385append: event.append,386items: event.outputItems387};388this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false);389}390break;391}392case NotebookCellsChangeType.Move: {393const result = adjustCellDiffAndOriginalModelBasedOnCellMovements(event, this._cellsDiffInfo.get().slice());394if (result) {395this.originalModel.applyEdits(result[1], true, undefined, () => undefined, undefined, false);396this._cellsDiffInfo.set(result[0], undefined);397}398break;399}400default: {401break;402}403}404}405406didResetToOriginalContent = this.initialContentComparer.isEqual(this.modifiedModel);407if (currentState === ModifiedFileEntryState.Modified && didResetToOriginalContent) {408this._stateObs.set(ModifiedFileEntryState.Rejected, undefined);409this.updateCellDiffInfo([], undefined);410this.initializeModelsFromDiff();411return;412}413}414415protected override async _doAccept(): Promise<void> {416this.updateCellDiffInfo([], undefined);417const snapshot = createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService);418restoreSnapshot(this.originalModel, snapshot);419this.initializeModelsFromDiff();420await this._collapse(undefined);421422const config = this._fileConfigService.getAutoSaveConfiguration(this.modifiedURI);423if (this.modifiedModel.uri.scheme !== Schemas.untitled && (!config.autoSave || !this.notebookResolver.isDirty(this.modifiedURI))) {424// SAVE after accept for manual-savers, for auto-savers425// trigger explict save to get save participants going426await this._applyEdits(async () => {427try {428await this.modifiedResourceRef.object.save({429reason: SaveReason.EXPLICIT,430force: true,431});432} catch {433// ignored434}435});436}437}438439protected override async _doReject(): Promise<void> {440this.updateCellDiffInfo([], undefined);441if (this.createdInRequestId === this._telemetryInfo.requestId) {442await this._applyEdits(async () => {443await this.modifiedResourceRef.object.revert({ soft: true });444await this._fileService.del(this.modifiedURI);445});446this._onDidDelete.fire();447} else {448await this._applyEdits(async () => {449const snapshot = createSnapshot(this.originalModel, this.transientOptions, this.configurationService);450this.restoreSnapshotInModifiedModel(snapshot);451if (this._allEditsAreFromUs && Array.from(this.cellEntryMap.values()).every(entry => entry.allEditsAreFromUs)) {452// save the file after discarding so that the dirty indicator goes away453// and so that an intermediate saved state gets reverted454await this.modifiedResourceRef.object.save({ reason: SaveReason.EXPLICIT, skipSaveParticipants: true });455}456});457this.initializeModelsFromDiff();458await this._collapse(undefined);459}460}461462private async _collapse(transaction: ITransaction | undefined): Promise<void> {463this._multiDiffEntryDelegate.collapse(transaction);464}465466protected override _createEditorIntegration(editor: IEditorPane): IModifiedFileEntryEditorIntegration {467const notebookEditor = getNotebookEditorFromEditorPane(editor);468if (!notebookEditor && editor.getId() === NotebookTextDiffEditor.ID) {469const diffEditor = (editor.getControl() as INotebookTextDiffEditor);470return this._instantiationService.createInstance(ChatEditingNotebookDiffEditorIntegration, diffEditor, this._cellsDiffInfo);471}472assertType(notebookEditor);473return this._instantiationService.createInstance(ChatEditingNotebookEditorIntegration, this, editor, this.modifiedModel, this.originalModel, this._cellsDiffInfo);474}475476protected override _resetEditsState(tx: ITransaction): void {477super._resetEditsState(tx);478this.cellEntryMap.forEach(entry => !entry.isDisposed && entry.clearCurrentEditLineDecoration());479}480481protected override _createUndoRedoElement(response: IChatResponseModel): IUndoRedoElement | undefined {482const request = response.session.getRequests().find(req => req.id === response.requestId);483const label = request?.message.text ? localize('chatNotebookEdit1', "Chat Edit: '{0}'", request.message.text) : localize('chatNotebookEdit2', "Chat Edit");484const transientOptions = this.transientOptions;485const outputSizeLimit = this.configurationService.getValue<number>(NotebookSetting.outputBackupSizeLimit) * 1024;486487// create a snapshot of the current state of the model, before the next set of edits488let initial = createSnapshot(this.modifiedModel, transientOptions, outputSizeLimit);489let last = '';490let redoState = ModifiedFileEntryState.Rejected;491492return {493type: UndoRedoElementType.Resource,494resource: this.modifiedURI,495label,496code: 'chat.edit',497confirmBeforeUndo: false,498undo: async () => {499last = createSnapshot(this.modifiedModel, transientOptions, outputSizeLimit);500this._isEditFromUs = true;501try {502restoreSnapshot(this.modifiedModel, initial);503restoreSnapshot(this.originalModel, initial);504} finally {505this._isEditFromUs = false;506}507redoState = this._stateObs.get() === ModifiedFileEntryState.Accepted ? ModifiedFileEntryState.Accepted : ModifiedFileEntryState.Rejected;508this._stateObs.set(ModifiedFileEntryState.Rejected, undefined);509this.updateCellDiffInfo([], undefined);510this.initializeModelsFromDiff();511this._notifySessionAction('userModified');512},513redo: async () => {514initial = createSnapshot(this.modifiedModel, transientOptions, outputSizeLimit);515this._isEditFromUs = true;516try {517restoreSnapshot(this.modifiedModel, last);518restoreSnapshot(this.originalModel, last);519} finally {520this._isEditFromUs = false;521}522this._stateObs.set(redoState, undefined);523this.updateCellDiffInfo([], undefined);524this.initializeModelsFromDiff();525this._notifySessionAction('userModified');526}527};528}529530protected override async _areOriginalAndModifiedIdentical(): Promise<boolean> {531return this._areOriginalAndModifiedIdenticalImpl();532}533534private _areOriginalAndModifiedIdenticalImpl(): boolean {535const snapshot = createSnapshot(this.originalModel, this.transientOptions, this.configurationService);536return new SnapshotComparer(snapshot).isEqual(this.modifiedModel);537}538539private newNotebookEditGenerator?: ChatEditingNewNotebookContentEdits;540override async acceptAgentEdits(resource: URI, edits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise<void> {541const isCellUri = resource.scheme === Schemas.vscodeNotebookCell;542const cell = isCellUri && this.modifiedModel.cells.find(cell => isEqual(cell.uri, resource));543let cellEntry: ChatEditingNotebookCellEntry | undefined;544if (cell) {545const index = this.modifiedModel.cells.indexOf(cell);546const entry = this._cellsDiffInfo.get().slice().find(entry => entry.modifiedCellIndex === index);547if (!entry) {548// Not possible.549console.error('Original cell model not found');550return;551}552553cellEntry = this.getOrCreateModifiedTextFileEntryForCell(cell, await entry.modifiedModel.promise, await entry.originalModel.promise);554}555556// For all cells that were edited, send the `isLastEdits` flag.557const finishPreviousCells = async () => {558await Promise.all(Array.from(this.editedCells).map(async (uri) => {559const cell = this.modifiedModel.cells.find(cell => isEqual(cell.uri, uri));560const cellEntry = cell && this.cellEntryMap.get(cell.uri);561await cellEntry?.acceptAgentEdits([], true, responseModel);562}));563this.editedCells.clear();564};565566await this._applyEdits(async () => {567await Promise.all(edits.map(async (edit, idx) => {568const last = isLastEdits && idx === edits.length - 1;569if (TextEdit.isTextEdit(edit)) {570// Possible we're getting the raw content for the notebook.571if (isEqual(resource, this.modifiedModel.uri)) {572this.newNotebookEditGenerator ??= this._instantiationService.createInstance(ChatEditingNewNotebookContentEdits, this.modifiedModel);573this.newNotebookEditGenerator.acceptTextEdits([edit]);574} else {575// If we get cell edits, its impossible to get text edits for the notebook uri.576this.newNotebookEditGenerator = undefined;577if (!this.editedCells.has(resource)) {578await finishPreviousCells();579this.editedCells.add(resource);580}581await cellEntry?.acceptAgentEdits([edit], last, responseModel);582}583} else {584// If we notebook edits, its impossible to get text edits for the notebook uri.585this.newNotebookEditGenerator = undefined;586this.acceptNotebookEdit(edit);587}588}));589});590591// If the last edit for a cell was sent, then handle it592if (isLastEdits) {593await finishPreviousCells();594}595596// isLastEdits can be true for cell Uris, but when its true for Cells edits.597// It cannot be true for the notebook itself.598isLastEdits = !isCellUri && isLastEdits;599600// If this is the last edit and & we got regular text edits for generating new notebook content601// Then generate notebook edits from those text edits & apply those notebook edits.602if (isLastEdits && this.newNotebookEditGenerator) {603const notebookEdits = await this.newNotebookEditGenerator.generateEdits();604this.newNotebookEditGenerator = undefined;605notebookEdits.forEach(edit => this.acceptNotebookEdit(edit));606}607608transaction((tx) => {609this._stateObs.set(ModifiedFileEntryState.Modified, tx);610this._isCurrentlyBeingModifiedByObs.set(responseModel, tx);611if (!isLastEdits) {612const newRewriteRation = Math.max(this._rewriteRatioObs.get(), calculateNotebookRewriteRatio(this._cellsDiffInfo.get(), this.originalModel, this.modifiedModel));613this._rewriteRatioObs.set(Math.min(1, newRewriteRation), tx);614} else {615this.editedCells.clear();616this._resetEditsState(tx);617this._rewriteRatioObs.set(1, tx);618}619});620}621622private disposeDeletedCellEntries() {623const cellsUris = new ResourceSet(this.modifiedModel.cells.map(cell => cell.uri));624Array.from(this.cellEntryMap.keys()).forEach(uri => {625if (cellsUris.has(uri)) {626return;627}628this.cellEntryMap.get(uri)?.dispose();629this.cellEntryMap.delete(uri);630});631}632633acceptNotebookEdit(edit: ICellEditOperation): void {634// make the actual edit635this.modifiedModel.applyEdits([edit], true, undefined, () => undefined, undefined, false);636this.disposeDeletedCellEntries();637638639if (edit.editType !== CellEditType.Replace) {640return;641}642// Ensure cells have internal Ids.643edit.cells.forEach((_, i) => {644const index = edit.index + i;645const cell = this.modifiedModel.cells[index];646if (cell.internalMetadata.internalId) {647return;648}649const internalId = generateCellHash(cell.uri);650const edits: ICellEditOperation[] = [{ editType: CellEditType.PartialInternalMetadata, index, internalMetadata: { internalId } }];651this.modifiedModel.applyEdits(edits, true, undefined, () => undefined, undefined, false);652});653654let diff: ICellDiffInfo[] = [];655if (edit.count === 0) {656// All existing indexes are shifted by number of cells added.657diff = sortCellChanges(this._cellsDiffInfo.get());658diff.forEach(d => {659if (d.type !== 'delete' && d.modifiedCellIndex >= edit.index) {660d.modifiedCellIndex += edit.cells.length;661}662});663const diffInsert = edit.cells.map((_, i) => this.createInsertedCellDiffInfo(edit.index + i));664diff.splice(edit.index, 0, ...diffInsert);665} else {666// All existing indexes are shifted by number of cells removed.667// And unchanged cells should be converted to deleted cells.668diff = sortCellChanges(this._cellsDiffInfo.get()).map((d) => {669if (d.type === 'unchanged' && d.modifiedCellIndex >= edit.index && d.modifiedCellIndex <= (edit.index + edit.count - 1)) {670return this.createDeleteCellDiffInfo(d.originalCellIndex);671}672if (d.type !== 'delete' && d.modifiedCellIndex >= (edit.index + edit.count)) {673d.modifiedCellIndex -= edit.count;674return d;675}676return d;677});678}679this.updateCellDiffInfo(diff, undefined);680}681682private computeStateAfterAcceptingRejectingChanges(accepted: boolean) {683const currentSnapshot = createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService);684if (new SnapshotComparer(currentSnapshot).isEqual(this.originalModel)) {685const state = accepted ? ModifiedFileEntryState.Accepted : ModifiedFileEntryState.Rejected;686this._stateObs.set(state, undefined);687this._notifySessionAction(accepted ? 'accepted' : 'rejected');688}689}690691createModifiedCellDiffInfo(modifiedCellIndex: number, originalCellIndex: number): ICellDiffInfo {692const modifiedCell = this.modifiedModel.cells[modifiedCellIndex];693const originalCell = this.originalModel.cells[originalCellIndex];694this.modifiedToOriginalCell.set(modifiedCell.uri, originalCell.uri);695const modifiedCellModelPromise = this.resolveCellModel(modifiedCell.uri);696const originalCellModelPromise = this.resolveCellModel(originalCell.uri);697698Promise.all([modifiedCellModelPromise, originalCellModelPromise]).then(([modifiedCellModel, originalCellModel]) => {699this.getOrCreateModifiedTextFileEntryForCell(modifiedCell, modifiedCellModel, originalCellModel);700});701702const diff = observableValue('diff', nullDocumentDiff);703const unchangedCell: ICellDiffInfo = {704type: 'unchanged',705modifiedCellIndex,706originalCellIndex,707keep: async (changes: DetailedLineRangeMapping) => {708const [modifiedCellModel, originalCellModel] = await Promise.all([modifiedCellModelPromise, originalCellModelPromise]);709const entry = this.getOrCreateModifiedTextFileEntryForCell(modifiedCell, modifiedCellModel, originalCellModel);710return entry ? entry.keep(changes) : false;711},712undo: async (changes: DetailedLineRangeMapping) => {713const [modifiedCellModel, originalCellModel] = await Promise.all([modifiedCellModelPromise, originalCellModelPromise]);714const entry = this.getOrCreateModifiedTextFileEntryForCell(modifiedCell, modifiedCellModel, originalCellModel);715return entry ? entry.undo(changes) : false;716},717modifiedModel: new ObservablePromise(modifiedCellModelPromise),718originalModel: new ObservablePromise(originalCellModelPromise),719diff720};721722return unchangedCell;723724}725createInsertedCellDiffInfo(modifiedCellIndex: number): ICellDiffInfo {726const cell = this.modifiedModel.cells[modifiedCellIndex];727const lines = cell.getValue().split(/\r?\n/);728const originalRange = new Range(1, 0, 1, 0);729const modifiedRange = new Range(1, 0, lines.length, lines[lines.length - 1].length);730const innerChanges = new RangeMapping(originalRange, modifiedRange);731const changes = [new DetailedLineRangeMapping(new LineRange(1, 1), new LineRange(1, lines.length), [innerChanges])];732// When a new cell is inserted, we use the ChatEditingCodeEditorIntegration to handle the edits.733// & to also display undo/redo and decorations.734// However that needs a modified and original model.735// For inserted cells there's no original model, so we create a new empty text model and pass that as the original.736const originalModelUri = this.modifiedModel.uri.with({ query: (ChatEditingModifiedNotebookEntry.NewModelCounter++).toString(), scheme: 'emptyCell' });737const originalModel = this.modelService.getModel(originalModelUri) || this._register(this.modelService.createModel('', null, originalModelUri));738this.modifiedToOriginalCell.set(cell.uri, originalModelUri);739const keep = async () => {740this._applyEditsSync(() => this.keepPreviouslyInsertedCell(cell));741this.computeStateAfterAcceptingRejectingChanges(true);742return true;743};744const undo = async () => {745this._applyEditsSync(() => this.undoPreviouslyInsertedCell(cell));746this.computeStateAfterAcceptingRejectingChanges(false);747return true;748};749this.resolveCellModel(cell.uri).then(modifiedModel => {750if (this._store.isDisposed) {751return;752}753// We want decorators for the cell just as we display decorators for modified cells.754// This way we have the ability to accept/reject the entire cell.755this.getOrCreateModifiedTextFileEntryForCell(cell, modifiedModel, originalModel);756});757return {758type: 'insert' as const,759originalCellIndex: undefined,760modifiedCellIndex: modifiedCellIndex,761keep,762undo,763modifiedModel: new ObservablePromise(this.resolveCellModel(cell.uri)),764originalModel: new ObservablePromise(Promise.resolve(originalModel)),765diff: observableValue('deletedCellDiff', {766changes,767identical: false,768moves: [],769quitEarly: false,770})771} satisfies ICellDiffInfo;772}773createDeleteCellDiffInfo(originalCellIndex: number): ICellDiffInfo {774const originalCell = this.originalModel.cells[originalCellIndex];775const lines = new Array(originalCell.textBuffer.getLineCount()).fill(0).map((_, i) => originalCell.textBuffer.getLineContent(i + 1));776const originalRange = new Range(1, 0, lines.length, lines[lines.length - 1].length);777const modifiedRange = new Range(1, 0, 1, 0);778const innerChanges = new RangeMapping(modifiedRange, originalRange);779const changes = [new DetailedLineRangeMapping(new LineRange(1, lines.length), new LineRange(1, 1), [innerChanges])];780const modifiedModelUri = this.modifiedModel.uri.with({ query: (ChatEditingModifiedNotebookEntry.NewModelCounter++).toString(), scheme: 'emptyCell' });781const modifiedModel = this.modelService.getModel(modifiedModelUri) || this._register(this.modelService.createModel('', null, modifiedModelUri));782const keep = async () => {783this._applyEditsSync(() => this.keepPreviouslyDeletedCell(this.originalModel.cells.indexOf(originalCell)));784this.computeStateAfterAcceptingRejectingChanges(true);785return true;786};787const undo = async () => {788this._applyEditsSync(() => this.undoPreviouslyDeletedCell(this.originalModel.cells.indexOf(originalCell), originalCell));789this.computeStateAfterAcceptingRejectingChanges(false);790return true;791};792793// This will be deleted.794return {795type: 'delete' as const,796modifiedCellIndex: undefined,797originalCellIndex,798originalModel: new ObservablePromise(this.resolveCellModel(originalCell.uri)),799modifiedModel: new ObservablePromise(Promise.resolve(modifiedModel)),800keep,801undo,802diff: observableValue('cellDiff', {803changes,804identical: false,805moves: [],806quitEarly: false,807})808} satisfies ICellDiffInfo;809}810811private undoPreviouslyInsertedCell(cell: NotebookCellTextModel) {812let diffs: ICellDiffInfo[] = [];813this._applyEditsSync(() => {814const index = this.modifiedModel.cells.indexOf(cell);815diffs = adjustCellDiffForRevertingAnInsertedCell(index,816this._cellsDiffInfo.get(),817this.modifiedModel.applyEdits.bind(this.modifiedModel));818});819this.disposeDeletedCellEntries();820this.updateCellDiffInfo(diffs, undefined);821}822823private keepPreviouslyInsertedCell(cell: NotebookCellTextModel) {824const modifiedCellIndex = this.modifiedModel.cells.indexOf(cell);825if (modifiedCellIndex === -1) {826// Not possible.827return;828}829const cellToInsert: ICellDto2 = {830cellKind: cell.cellKind,831language: cell.language,832metadata: cell.metadata,833outputs: cell.outputs,834source: cell.getValue(),835mime: cell.mime,836internalMetadata: {837internalId: cell.internalMetadata.internalId838}839};840this.cellEntryMap.get(cell.uri)?.dispose();841this.cellEntryMap.delete(cell.uri);842const cellDiffs = adjustCellDiffForKeepingAnInsertedCell(843modifiedCellIndex,844this._cellsDiffInfo.get().slice(),845cellToInsert,846this.originalModel.applyEdits.bind(this.originalModel),847this.createModifiedCellDiffInfo.bind(this)848);849this.updateCellDiffInfo(cellDiffs, undefined);850}851852private undoPreviouslyDeletedCell(deletedOriginalIndex: number, originalCell: NotebookCellTextModel) {853const cellToInsert: ICellDto2 = {854cellKind: originalCell.cellKind,855language: originalCell.language,856metadata: originalCell.metadata,857outputs: originalCell.outputs,858source: originalCell.getValue(),859mime: originalCell.mime,860internalMetadata: {861internalId: originalCell.internalMetadata.internalId862}863};864let cellDiffs: ICellDiffInfo[] = [];865this._applyEditsSync(() => {866cellDiffs = adjustCellDiffForRevertingADeletedCell(867deletedOriginalIndex,868this._cellsDiffInfo.get(),869cellToInsert,870this.modifiedModel.applyEdits.bind(this.modifiedModel),871this.createModifiedCellDiffInfo.bind(this)872);873});874this.updateCellDiffInfo(cellDiffs, undefined);875}876877878private keepPreviouslyDeletedCell(deletedOriginalIndex: number) {879// Delete this cell from original as well.880const edit: ICellReplaceEdit = { cells: [], count: 1, editType: CellEditType.Replace, index: deletedOriginalIndex, };881this.originalModel.applyEdits([edit], true, undefined, () => undefined, undefined, false);882const diffs = sortCellChanges(this._cellsDiffInfo.get())883.filter(d => !(d.type === 'delete' && d.originalCellIndex === deletedOriginalIndex))884.map(diff => {885if (diff.type !== 'insert' && diff.originalCellIndex > deletedOriginalIndex) {886return {887...diff,888originalCellIndex: diff.originalCellIndex - 1,889};890}891return diff;892});893this.updateCellDiffInfo(diffs, undefined);894}895896private async _applyEdits(operation: () => Promise<void>) {897// make the actual edit898this._isEditFromUs = true;899try {900await operation();901} finally {902this._isEditFromUs = false;903}904}905906private _applyEditsSync(operation: () => void) {907// make the actual edit908this._isEditFromUs = true;909try {910operation();911} finally {912this._isEditFromUs = false;913}914}915916override createSnapshot(requestId: string | undefined, undoStop: string | undefined): ISnapshotEntry {917return {918resource: this.modifiedURI,919languageId: SnapshotLanguageId,920snapshotUri: getNotebookSnapshotFileURI(this._telemetryInfo.sessionId, requestId, undoStop, this.modifiedURI.path, this.modifiedModel.viewType),921original: createSnapshot(this.originalModel, this.transientOptions, this.configurationService),922current: createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService),923state: this.state.get(),924telemetryInfo: this.telemetryInfo,925};926}927928override equalsSnapshot(snapshot: ISnapshotEntry | undefined): boolean {929return !!snapshot &&930isEqual(this.modifiedURI, snapshot.resource) &&931this.state.get() === snapshot.state &&932new SnapshotComparer(snapshot.original).isEqual(this.originalModel) &&933new SnapshotComparer(snapshot.current).isEqual(this.modifiedModel);934935}936937override async restoreFromSnapshot(snapshot: ISnapshotEntry, restoreToDisk = true): Promise<void> {938this.updateCellDiffInfo([], undefined);939this._stateObs.set(snapshot.state, undefined);940restoreSnapshot(this.originalModel, snapshot.original);941if (restoreToDisk) {942this.restoreSnapshotInModifiedModel(snapshot.current);943}944this.initializeModelsFromDiff();945}946947override async resetToInitialContent(): Promise<void> {948this.updateCellDiffInfo([], undefined);949this.restoreSnapshotInModifiedModel(this.initialContent);950this.initializeModelsFromDiff();951}952953private restoreSnapshotInModifiedModel(snapshot: string) {954if (snapshot === createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService)) {955return;956}957958this._applyEditsSync(() => {959// See private _setDocValue in chatEditingModifiedDocumentEntry.ts960this.modifiedModel.pushStackElement();961restoreSnapshot(this.modifiedModel, snapshot);962this.modifiedModel.pushStackElement();963});964}965966private readonly cellTextModelMap = new ResourceMap<ITextModel>();967968private async resolveCellModel(cellURI: URI): Promise<ITextModel> {969const cell = this.originalModel.cells.concat(this.modifiedModel.cells).find(cell => isEqual(cell.uri, cellURI));970if (!cell) {971throw new Error('Cell not found');972}973const model = this.cellTextModelMap.get(cell.uri);974if (model) {975this.cellTextModelMap.set(cell.uri, model);976return model;977} else {978const textEditorModel = await thenRegisterOrDispose(this.textModelService.createModelReference(cell.uri), this._store);979const model = textEditorModel.object.textEditorModel;980this.cellTextModelMap.set(cell.uri, model);981return model;982}983}984985getOrCreateModifiedTextFileEntryForCell(cell: NotebookCellTextModel, modifiedCellModel: ITextModel, originalCellModel: ITextModel): ChatEditingNotebookCellEntry | undefined {986let cellEntry = this.cellEntryMap.get(cell.uri);987if (cellEntry) {988return cellEntry;989}990if (this._store.isDisposed) {991return;992}993const disposables = new DisposableStore();994cellEntry = this._register(this._instantiationService.createInstance(ChatEditingNotebookCellEntry, this.modifiedResourceRef.object.resource, cell, modifiedCellModel, originalCellModel, disposables));995this.cellEntryMap.set(cell.uri, cellEntry);996disposables.add(autorun(r => {997if (this.modifiedModel.cells.indexOf(cell) === -1) {998return;999}1000const diffs = this.cellsDiffInfo.get().slice();1001const index = this.modifiedModel.cells.indexOf(cell);1002let entry = diffs.find(entry => entry.modifiedCellIndex === index);1003if (!entry) {1004// Not possible.1005return;1006}1007const entryIndex = diffs.indexOf(entry);1008entry.diff.set(cellEntry.diffInfo.read(r), undefined);1009if (cellEntry.diffInfo.get().identical && entry.type === 'modified') {1010entry = {1011...entry,1012type: 'unchanged',1013};1014}1015if (!cellEntry.diffInfo.get().identical && entry.type === 'unchanged') {1016entry = {1017...entry,1018type: 'modified',1019};1020}1021diffs.splice(entryIndex, 1, { ...entry });10221023transaction(tx => {1024this.updateCellDiffInfo(diffs, tx);1025});1026}));10271028disposables.add(autorun(r => {1029if (this.modifiedModel.cells.indexOf(cell) === -1) {1030return;1031}10321033const cellState = cellEntry.state.read(r);1034if (cellState === ModifiedFileEntryState.Accepted) {1035this.computeStateAfterAcceptingRejectingChanges(true);1036} else if (cellState === ModifiedFileEntryState.Rejected) {1037this.computeStateAfterAcceptingRejectingChanges(false);1038}1039}));10401041return cellEntry;1042}1043}104410451046function generateCellHash(cellUri: URI) {1047const hash = new StringSHA1();1048hash.update(cellUri.toString());1049return hash.digest().substring(0, 8);1050}105110521053