Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.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 { addDisposableListener, getWindow } from '../../../../../base/browser/dom.js';6import { assert } from '../../../../../base/common/assert.js';7import { DeferredPromise, RunOnceScheduler, timeout } from '../../../../../base/common/async.js';8import { Emitter } from '../../../../../base/common/event.js';9import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js';10import { autorun, IObservable, observableValue } from '../../../../../base/common/observable.js';11import { isEqual } from '../../../../../base/common/resources.js';12import { themeColorFromId } from '../../../../../base/common/themables.js';13import { assertType } from '../../../../../base/common/types.js';14import { URI } from '../../../../../base/common/uri.js';15import { EditOperation, ISingleEditOperation } from '../../../../../editor/common/core/editOperation.js';16import { StringEdit } from '../../../../../editor/common/core/edits/stringEdit.js';17import { Range } from '../../../../../editor/common/core/range.js';18import { IDocumentDiff, nullDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js';19import { DetailedLineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js';20import { TextEdit, VersionedExtensionId } from '../../../../../editor/common/languages.js';21import { IModelDeltaDecoration, ITextModel, ITextSnapshot, MinimapPosition, OverviewRulerLane } from '../../../../../editor/common/model.js';22import { ModelDecorationOptions } from '../../../../../editor/common/model/textModel.js';23import { offsetEditFromContentChanges, offsetEditFromLineRangeMapping, offsetEditToEditOperations } from '../../../../../editor/common/model/textModelStringEdit.js';24import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js';25import { TextModelEditSource, EditSources } from '../../../../../editor/common/textModelEditSource.js';26import { IModelContentChangedEvent } from '../../../../../editor/common/textModelEvents.js';27import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';28import { editorSelectionBackground } from '../../../../../platform/theme/common/colorRegistry.js';29import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js';30import { ModifiedFileEntryState } from '../../common/chatEditingService.js';31import { IChatResponseModel } from '../../common/chatModel.js';32import { IDocumentDiff2 } from './chatEditingCodeEditorIntegration.js';33import { pendingRewriteMinimap } from './chatEditingModifiedFileEntry.js';3435type affectedLines = { linesAdded: number; linesRemoved: number; lineCount: number; hasRemainingEdits: boolean };36type acceptedOrRejectedLines = affectedLines & { state: 'accepted' | 'rejected' };3738export class ChatEditingTextModelChangeService extends Disposable {3940private static readonly _lastEditDecorationOptions = ModelDecorationOptions.register({41isWholeLine: true,42description: 'chat-last-edit',43className: 'chat-editing-last-edit-line',44marginClassName: 'chat-editing-last-edit',45overviewRuler: {46position: OverviewRulerLane.Full,47color: themeColorFromId(editorSelectionBackground)48},49});5051private static readonly _pendingEditDecorationOptions = ModelDecorationOptions.register({52isWholeLine: true,53description: 'chat-pending-edit',54className: 'chat-editing-pending-edit',55minimap: {56position: MinimapPosition.Inline,57color: themeColorFromId(pendingRewriteMinimap)58}59});6061private static readonly _atomicEditDecorationOptions = ModelDecorationOptions.register({62isWholeLine: true,63description: 'chat-atomic-edit',64className: 'chat-editing-atomic-edit',65minimap: {66position: MinimapPosition.Inline,67color: themeColorFromId(pendingRewriteMinimap)68}69});7071private _isEditFromUs: boolean = false;72public get isEditFromUs() {73return this._isEditFromUs;74}75private _allEditsAreFromUs: boolean = true;76public get allEditsAreFromUs() {77return this._allEditsAreFromUs;78}79private _diffOperation: Promise<IDocumentDiff | undefined> | undefined;80private _diffOperationIds: number = 0;8182private readonly _diffInfo = observableValue<IDocumentDiff>(this, nullDocumentDiff);83public get diffInfo() {84return this._diffInfo.map(value => {85return {86...value,87originalModel: this.originalModel,88modifiedModel: this.modifiedModel,89keep: changes => this._keepHunk(changes),90undo: changes => this._undoHunk(changes)91} satisfies IDocumentDiff2;92});93}9495private readonly _editDecorationClear = this._register(new RunOnceScheduler(() => { this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, []); }, 500));96private _editDecorations: string[] = [];9798private readonly _didAcceptOrRejectAllHunks = this._register(new Emitter<ModifiedFileEntryState.Accepted | ModifiedFileEntryState.Rejected>());99public readonly onDidAcceptOrRejectAllHunks = this._didAcceptOrRejectAllHunks.event;100101private readonly _didAcceptOrRejectLines = this._register(new Emitter<acceptedOrRejectedLines>());102public readonly onDidAcceptOrRejectLines = this._didAcceptOrRejectLines.event;103104private notifyHunkAction(state: 'accepted' | 'rejected', affectedLines: affectedLines) {105if (affectedLines.lineCount > 0) {106this._didAcceptOrRejectLines.fire({ state, ...affectedLines });107}108}109110private readonly _didUserEditModel = this._register(new Emitter<void>());111public readonly onDidUserEditModel = this._didUserEditModel.event;112113private _originalToModifiedEdit: StringEdit = StringEdit.empty;114115private lineChangeCount: number = 0;116private linesAdded: number = 0;117private linesRemoved: number = 0;118119constructor(120private readonly originalModel: ITextModel,121private readonly modifiedModel: ITextModel,122private readonly state: IObservable<ModifiedFileEntryState>,123@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,124@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService,125) {126super();127this._register(this.modifiedModel.onDidChangeContent(e => {128this._mirrorEdits(e);129}));130131this._register(toDisposable(() => {132this.clearCurrentEditLineDecoration();133}));134135this._register(autorun(r => this.updateLineChangeCount(this._diffInfo.read(r))));136}137138private updateLineChangeCount(diff: IDocumentDiff) {139this.lineChangeCount = 0;140this.linesAdded = 0;141this.linesRemoved = 0;142143for (const change of diff.changes) {144const modifiedRange = change.modified.endLineNumberExclusive - change.modified.startLineNumber;145this.linesAdded += Math.max(0, modifiedRange);146const originalRange = change.original.endLineNumberExclusive - change.original.startLineNumber;147this.linesRemoved += Math.max(0, originalRange);148149this.lineChangeCount += Math.max(modifiedRange, originalRange);150}151}152153public clearCurrentEditLineDecoration() {154this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, []);155}156157public async areOriginalAndModifiedIdentical(): Promise<boolean> {158const diff = await this._diffOperation;159return diff ? diff.identical : false;160}161162async acceptAgentEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise<{ rewriteRatio: number; maxLineNumber: number }> {163164assertType(textEdits.every(TextEdit.isTextEdit), 'INVALID args, can only handle text edits');165assert(isEqual(resource, this.modifiedModel.uri), ' INVALID args, can only edit THIS document');166167const isAtomicEdits = textEdits.length > 0 && isLastEdits;168let maxLineNumber = 0;169let rewriteRatio = 0;170171const sessionId = responseModel.session.sessionId;172const request = responseModel.session.getRequests().at(-1);173const languageId = this.modifiedModel.getLanguageId();174const agent = responseModel.agent;175const extensionId = VersionedExtensionId.tryCreate(agent?.extensionId.value, agent?.extensionVersion);176177const source = EditSources.chatApplyEdits({178modelId: request?.modelId,179requestId: request?.id,180sessionId: sessionId,181languageId,182mode: request?.modeInfo?.modeId,183extensionId,184codeBlockSuggestionId: request?.modeInfo?.applyCodeBlockSuggestionId,185});186187if (isAtomicEdits) {188// EDIT and DONE189const minimalEdits = await this._editorWorkerService.computeMoreMinimalEdits(this.modifiedModel.uri, textEdits) ?? textEdits;190const ops = minimalEdits.map(TextEdit.asEditOperation);191const undoEdits = this._applyEdits(ops, source);192193if (undoEdits.length > 0) {194let range: Range | undefined;195for (let i = 0; i < undoEdits.length; i++) {196const op = undoEdits[i];197if (!range) {198range = Range.lift(op.range);199} else {200range = Range.plusRange(range, op.range);201}202}203if (range) {204205const defer = new DeferredPromise<void>();206const listener = addDisposableListener(getWindow(undefined), 'animationend', e => {207if (e.animationName === 'kf-chat-editing-atomic-edit') { // CHECK with chat.css208defer.complete();209listener.dispose();210}211});212213this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, [{214options: ChatEditingTextModelChangeService._atomicEditDecorationOptions,215range216}]);217218await Promise.any([defer.p, timeout(500)]); // wait for animation to finish but also time-cap it219listener.dispose();220}221}222223224} else {225// EDIT a bit, then DONE226const ops = textEdits.map(TextEdit.asEditOperation);227const undoEdits = this._applyEdits(ops, source);228maxLineNumber = undoEdits.reduce((max, op) => Math.max(max, op.range.startLineNumber), 0);229rewriteRatio = Math.min(1, maxLineNumber / this.modifiedModel.getLineCount());230231const newDecorations: IModelDeltaDecoration[] = [232// decorate pending edit (region)233{234options: ChatEditingTextModelChangeService._pendingEditDecorationOptions,235range: new Range(maxLineNumber + 1, 1, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)236}237];238239if (maxLineNumber > 0) {240// decorate last edit241newDecorations.push({242options: ChatEditingTextModelChangeService._lastEditDecorationOptions,243range: new Range(maxLineNumber, 1, maxLineNumber, Number.MAX_SAFE_INTEGER)244});245}246this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, newDecorations);247248}249250if (isLastEdits) {251this._updateDiffInfoSeq();252this._editDecorationClear.schedule();253}254255return { rewriteRatio, maxLineNumber };256}257258private _applyEdits(edits: ISingleEditOperation[], source: TextModelEditSource) {259try {260this._isEditFromUs = true;261// make the actual edit262let result: ISingleEditOperation[] = [];263264this.modifiedModel.pushEditOperations(null, edits, (undoEdits) => {265result = undoEdits;266return null;267}, undefined, source);268269return result;270} finally {271this._isEditFromUs = false;272}273}274275/**276* Keeps the current modified document as the final contents.277*/278public keep() {279this.notifyHunkAction('accepted', { linesAdded: this.linesAdded, linesRemoved: this.linesRemoved, lineCount: this.lineChangeCount, hasRemainingEdits: false });280this.originalModel.setValue(this.modifiedModel.createSnapshot());281this._diffInfo.set(nullDocumentDiff, undefined);282this._originalToModifiedEdit = StringEdit.empty;283}284285/**286* Undoes the current modified document as the final contents.287*/288public undo() {289this.notifyHunkAction('rejected', { linesAdded: this.linesAdded, linesRemoved: this.linesRemoved, lineCount: this.lineChangeCount, hasRemainingEdits: false });290this.modifiedModel.pushStackElement();291this._applyEdits([(EditOperation.replace(this.modifiedModel.getFullModelRange(), this.originalModel.getValue()))], EditSources.chatUndoEdits());292this.modifiedModel.pushStackElement();293this._originalToModifiedEdit = StringEdit.empty;294this._diffInfo.set(nullDocumentDiff, undefined);295}296297public async resetDocumentValues(newOriginal: string | ITextSnapshot | undefined, newModified: string | undefined): Promise<void> {298let didChange = false;299if (newOriginal !== undefined) {300this.originalModel.setValue(newOriginal);301didChange = true;302}303if (newModified !== undefined && this.modifiedModel.getValue() !== newModified) {304// NOTE that this isn't done via `setValue` so that the undo stack is preserved305this.modifiedModel.pushStackElement();306this._applyEdits([(EditOperation.replace(this.modifiedModel.getFullModelRange(), newModified))], EditSources.chatReset());307this.modifiedModel.pushStackElement();308didChange = true;309}310if (didChange) {311await this._updateDiffInfoSeq();312}313}314315private _mirrorEdits(event: IModelContentChangedEvent) {316const edit = offsetEditFromContentChanges(event.changes);317318if (this._isEditFromUs) {319const e_sum = this._originalToModifiedEdit;320const e_ai = edit;321this._originalToModifiedEdit = e_sum.compose(e_ai);322} else {323324// e_ai325// d0 ---------------> s0326// | |327// | |328// | e_user_r | e_user329// | |330// | |331// v e_ai_r v332/// d1 ---------------> s1333//334// d0 - document snapshot335// s0 - document336// e_ai - ai edits337// e_user - user edits338//339const e_ai = this._originalToModifiedEdit;340const e_user = edit;341342const e_user_r = e_user.tryRebase(e_ai.inverse(this.originalModel.getValue()));343344if (e_user_r === undefined) {345// user edits overlaps/conflicts with AI edits346this._originalToModifiedEdit = e_ai.compose(e_user);347} else {348const edits = offsetEditToEditOperations(e_user_r, this.originalModel);349this.originalModel.applyEdits(edits);350this._originalToModifiedEdit = e_ai.rebaseSkipConflicting(e_user_r);351}352353this._allEditsAreFromUs = false;354this._updateDiffInfoSeq();355this._didUserEditModel.fire();356}357}358359private async _keepHunk(change: DetailedLineRangeMapping): Promise<boolean> {360if (!this._diffInfo.get().changes.includes(change)) {361// diffInfo should have model version ids and check them (instead of the caller doing that)362return false;363}364const edits: ISingleEditOperation[] = [];365for (const edit of change.innerChanges ?? []) {366const newText = this.modifiedModel.getValueInRange(edit.modifiedRange);367edits.push(EditOperation.replace(edit.originalRange, newText));368}369this.originalModel.pushEditOperations(null, edits, _ => null);370await this._updateDiffInfoSeq('accepted');371if (this._diffInfo.get().identical) {372this._didAcceptOrRejectAllHunks.fire(ModifiedFileEntryState.Accepted);373}374this._accessibilitySignalService.playSignal(AccessibilitySignal.editsKept, { allowManyInParallel: true });375return true;376}377378private async _undoHunk(change: DetailedLineRangeMapping): Promise<boolean> {379if (!this._diffInfo.get().changes.includes(change)) {380return false;381}382const edits: ISingleEditOperation[] = [];383for (const edit of change.innerChanges ?? []) {384const newText = this.originalModel.getValueInRange(edit.originalRange);385edits.push(EditOperation.replace(edit.modifiedRange, newText));386}387this.modifiedModel.pushEditOperations(null, edits, _ => null);388await this._updateDiffInfoSeq('rejected');389if (this._diffInfo.get().identical) {390this._didAcceptOrRejectAllHunks.fire(ModifiedFileEntryState.Rejected);391}392this._accessibilitySignalService.playSignal(AccessibilitySignal.editsUndone, { allowManyInParallel: true });393return true;394}395396397private async _updateDiffInfoSeq(notifyAction: 'accepted' | 'rejected' | undefined = undefined) {398const myDiffOperationId = ++this._diffOperationIds;399await Promise.resolve(this._diffOperation);400const previousCount = this.lineChangeCount;401const previousAdded = this.linesAdded;402const previousRemoved = this.linesRemoved;403if (this._diffOperationIds === myDiffOperationId) {404const thisDiffOperation = this._updateDiffInfo();405this._diffOperation = thisDiffOperation;406await thisDiffOperation;407if (notifyAction) {408const affectedLines = {409linesAdded: previousAdded - this.linesAdded,410linesRemoved: previousRemoved - this.linesRemoved,411lineCount: previousCount - this.lineChangeCount,412hasRemainingEdits: this.lineChangeCount > 0413};414this.notifyHunkAction(notifyAction, affectedLines);415}416}417}418419private async _updateDiffInfo(): Promise<IDocumentDiff | undefined> {420421if (this.originalModel.isDisposed() || this.modifiedModel.isDisposed() || this._store.isDisposed) {422return undefined;423}424425if (this.state.get() !== ModifiedFileEntryState.Modified) {426this._diffInfo.set(nullDocumentDiff, undefined);427this._originalToModifiedEdit = StringEdit.empty;428return nullDocumentDiff;429}430431const docVersionNow = this.modifiedModel.getVersionId();432const snapshotVersionNow = this.originalModel.getVersionId();433434const diff = await this._editorWorkerService.computeDiff(435this.originalModel.uri,436this.modifiedModel.uri,437{438ignoreTrimWhitespace: false, // NEVER ignore whitespace so that undo/accept edits are correct and so that all changes (1 of 2) are spelled out439computeMoves: false,440maxComputationTimeMs: 3000441},442'advanced'443);444445if (this.originalModel.isDisposed() || this.modifiedModel.isDisposed() || this._store.isDisposed) {446return undefined;447}448449// only update the diff if the documents didn't change in the meantime450if (this.modifiedModel.getVersionId() === docVersionNow && this.originalModel.getVersionId() === snapshotVersionNow) {451const diff2 = diff ?? nullDocumentDiff;452this._diffInfo.set(diff2, undefined);453this._originalToModifiedEdit = offsetEditFromLineRangeMapping(this.originalModel, this.modifiedModel, diff2.changes);454return diff2;455}456return undefined;457}458}459460461