Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.ts
5222 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 { IRange, Range } from '../../../../../editor/common/core/range.js';18import { LineRange } from '../../../../../editor/common/core/ranges/lineRange.js';19import { IDocumentDiff, nullDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js';20import { DetailedLineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js';21import { TextEdit, VersionedExtensionId } from '../../../../../editor/common/languages.js';22import { IModelDeltaDecoration, ITextModel, ITextSnapshot, MinimapPosition, OverviewRulerLane } from '../../../../../editor/common/model.js';23import { ModelDecorationOptions } from '../../../../../editor/common/model/textModel.js';24import { offsetEditFromContentChanges, offsetEditFromLineRangeMapping, offsetEditToEditOperations } from '../../../../../editor/common/model/textModelStringEdit.js';25import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js';26import { EditSources, TextModelEditSource } from '../../../../../editor/common/textModelEditSource.js';27import { IModelContentChangedEvent } from '../../../../../editor/common/textModelEvents.js';28import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';29import { editorSelectionBackground } from '../../../../../platform/theme/common/colorRegistry.js';30import { ICellEditOperation } from '../../../notebook/common/notebookCommon.js';31import { ModifiedFileEntryState } from '../../common/editing/chatEditingService.js';32import { IChatResponseModel } from '../../common/model/chatModel.js';33import { ChatAgentLocation } from '../../common/constants.js';34import { IDocumentDiff2 } from './chatEditingCodeEditorIntegration.js';35import { pendingRewriteMinimap } from './chatEditingModifiedFileEntry.js';3637type affectedLines = { linesAdded: number; linesRemoved: number; lineCount: number; hasRemainingEdits: boolean };38type acceptedOrRejectedLines = affectedLines & { state: 'accepted' | 'rejected' };3940export class ChatEditingTextModelChangeService extends Disposable {4142private static readonly _lastEditDecorationOptions = ModelDecorationOptions.register({43isWholeLine: true,44description: 'chat-last-edit',45className: 'chat-editing-last-edit-line',46marginClassName: 'chat-editing-last-edit',47overviewRuler: {48position: OverviewRulerLane.Full,49color: themeColorFromId(editorSelectionBackground)50},51});5253private static readonly _pendingEditDecorationOptions = ModelDecorationOptions.register({54isWholeLine: true,55description: 'chat-pending-edit',56className: 'chat-editing-pending-edit',57minimap: {58position: MinimapPosition.Inline,59color: themeColorFromId(pendingRewriteMinimap)60}61});6263private static readonly _atomicEditDecorationOptions = ModelDecorationOptions.register({64isWholeLine: true,65description: 'chat-atomic-edit',66className: 'chat-editing-atomic-edit',67minimap: {68position: MinimapPosition.Inline,69color: themeColorFromId(pendingRewriteMinimap)70}71});7273private _isEditFromUs: boolean = false;74public get isEditFromUs() {75return this._isEditFromUs;76}77private _allEditsAreFromUs: boolean = true;78public get allEditsAreFromUs() {79return this._allEditsAreFromUs;80}81private _isExternalEditInProgress: (() => boolean) | undefined;82private _diffOperation: Promise<IDocumentDiff | undefined> | undefined;83private _diffOperationIds: number = 0;8485private readonly _diffInfo = observableValue<IDocumentDiff>(this, nullDocumentDiff);86public get diffInfo() {87return this._diffInfo.map(value => {88return {89...value,90originalModel: this.originalModel,91modifiedModel: this.modifiedModel,92keep: changes => this._keepHunk(changes),93undo: changes => this._undoHunk(changes)94} satisfies IDocumentDiff2;95});96}9798private readonly _editDecorationClear = this._register(new RunOnceScheduler(() => { this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, []); }, 500));99private _editDecorations: string[] = [];100101private readonly _didAcceptOrRejectAllHunks = this._register(new Emitter<ModifiedFileEntryState.Accepted | ModifiedFileEntryState.Rejected>());102public readonly onDidAcceptOrRejectAllHunks = this._didAcceptOrRejectAllHunks.event;103104private readonly _didAcceptOrRejectLines = this._register(new Emitter<acceptedOrRejectedLines>());105public readonly onDidAcceptOrRejectLines = this._didAcceptOrRejectLines.event;106107private notifyHunkAction(state: 'accepted' | 'rejected', affectedLines: affectedLines) {108if (affectedLines.lineCount > 0) {109this._didAcceptOrRejectLines.fire({ state, ...affectedLines });110}111}112113private _didUserEditModelFired = false;114private readonly _didUserEditModel = this._register(new Emitter<void>());115public readonly onDidUserEditModel = this._didUserEditModel.event;116117private _originalToModifiedEdit: StringEdit = StringEdit.empty;118119private lineChangeCount: number = 0;120private linesAdded: number = 0;121private linesRemoved: number = 0;122123constructor(124private readonly originalModel: ITextModel,125private readonly modifiedModel: ITextModel,126private readonly state: IObservable<ModifiedFileEntryState>,127isExternalEditInProgress: (() => boolean) | undefined,128@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,129@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService,130) {131super();132this._isExternalEditInProgress = isExternalEditInProgress;133this._register(this.modifiedModel.onDidChangeContent(e => {134this._mirrorEdits(e);135}));136137this._register(toDisposable(() => {138this.clearCurrentEditLineDecoration();139}));140141this._register(autorun(r => this.updateLineChangeCount(this._diffInfo.read(r))));142143if (!originalModel.equalsTextBuffer(modifiedModel.getTextBuffer())) {144this._updateDiffInfoSeq();145}146}147148private updateLineChangeCount(diff: IDocumentDiff) {149this.lineChangeCount = 0;150this.linesAdded = 0;151this.linesRemoved = 0;152153for (const change of diff.changes) {154const modifiedRange = change.modified.endLineNumberExclusive - change.modified.startLineNumber;155this.linesAdded += Math.max(0, modifiedRange);156const originalRange = change.original.endLineNumberExclusive - change.original.startLineNumber;157this.linesRemoved += Math.max(0, originalRange);158159this.lineChangeCount += Math.max(modifiedRange, originalRange);160}161}162163public clearCurrentEditLineDecoration() {164if (!this.modifiedModel.isDisposed()) {165this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, []);166}167}168169public async areOriginalAndModifiedIdentical(): Promise<boolean> {170const diff = await this._diffOperation;171return diff ? diff.identical : false;172}173174async acceptAgentEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel | undefined): Promise<{ rewriteRatio: number; maxLineNumber: number }> {175176assertType(textEdits.every(TextEdit.isTextEdit), 'INVALID args, can only handle text edits');177assert(isEqual(resource, this.modifiedModel.uri), ' INVALID args, can only edit THIS document');178179const isAtomicEdits = textEdits.length > 0 && isLastEdits;180let maxLineNumber = 0;181let rewriteRatio = 0;182183const source = this._createEditSource(responseModel);184185if (isAtomicEdits) {186// EDIT and DONE187const minimalEdits = await this._editorWorkerService.computeMoreMinimalEdits(this.modifiedModel.uri, textEdits) ?? textEdits;188const ops = minimalEdits.map(TextEdit.asEditOperation);189const undoEdits = this._applyEdits(ops, source);190191if (undoEdits.length > 0) {192let range: Range | undefined;193for (let i = 0; i < undoEdits.length; i++) {194const op = undoEdits[i];195if (!range) {196range = Range.lift(op.range);197} else {198range = Range.plusRange(range, op.range);199}200}201if (range) {202203const defer = new DeferredPromise<void>();204const listener = addDisposableListener(getWindow(undefined), 'animationend', e => {205if (e.animationName === 'kf-chat-editing-atomic-edit') { // CHECK with chat.css206defer.complete();207listener.dispose();208}209});210211this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, [{212options: ChatEditingTextModelChangeService._atomicEditDecorationOptions,213range214}]);215216await Promise.any([defer.p, timeout(500)]); // wait for animation to finish but also time-cap it217listener.dispose();218}219}220221222} else {223// EDIT a bit, then DONE224const ops = textEdits.map(TextEdit.asEditOperation);225const undoEdits = this._applyEdits(ops, source);226maxLineNumber = undoEdits.reduce((max, op) => Math.max(max, op.range.startLineNumber), 0);227rewriteRatio = Math.min(1, maxLineNumber / this.modifiedModel.getLineCount());228229const newDecorations: IModelDeltaDecoration[] = [230// decorate pending edit (region)231{232options: ChatEditingTextModelChangeService._pendingEditDecorationOptions,233range: new Range(maxLineNumber + 1, 1, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)234}235];236237if (maxLineNumber > 0) {238// decorate last edit239newDecorations.push({240options: ChatEditingTextModelChangeService._lastEditDecorationOptions,241range: new Range(maxLineNumber, 1, maxLineNumber, Number.MAX_SAFE_INTEGER)242});243}244this._editDecorations = this.modifiedModel.deltaDecorations(this._editDecorations, newDecorations);245246}247248if (isLastEdits) {249this._updateDiffInfoSeq();250this._editDecorationClear.schedule();251}252253return { rewriteRatio, maxLineNumber };254}255256private _createEditSource(responseModel: IChatResponseModel | undefined) {257258if (!responseModel) {259return EditSources.unknown({ name: 'editSessionUndoRedo' });260}261262const sessionId = responseModel.session.sessionId;263const request = responseModel.session.getRequests().at(-1);264const languageId = this.modifiedModel.getLanguageId();265const agent = responseModel.agent;266const extensionId = VersionedExtensionId.tryCreate(agent?.extensionId.value, agent?.extensionVersion);267268if (responseModel.request?.locationData?.type === ChatAgentLocation.EditorInline) {269270return EditSources.inlineChatApplyEdit({271modelId: request?.modelId,272requestId: request?.id,273sessionId,274languageId,275extensionId,276});277}278279return EditSources.chatApplyEdits({280modelId: request?.modelId,281requestId: request?.id,282sessionId,283languageId,284mode: request?.modeInfo?.modeId,285extensionId,286codeBlockSuggestionId: request?.modeInfo?.applyCodeBlockSuggestionId,287});288}289290private _applyEdits(edits: ISingleEditOperation[], source: TextModelEditSource) {291292if (edits.length === 0) {293return [];294}295296try {297this._isEditFromUs = true;298// make the actual edit299let result: ISingleEditOperation[] = [];300301this.modifiedModel.pushEditOperations(null, edits, (undoEdits) => {302result = undoEdits;303return null;304}, undefined, source);305306return result;307} finally {308this._isEditFromUs = false;309}310}311312/**313* Keeps the current modified document as the final contents.314*/315public keep() {316this.notifyHunkAction('accepted', { linesAdded: this.linesAdded, linesRemoved: this.linesRemoved, lineCount: this.lineChangeCount, hasRemainingEdits: false });317this.originalModel.setValue(this.modifiedModel.createSnapshot());318this._reset();319}320321/**322* Undoes the current modified document as the final contents.323*/324public undo() {325this.notifyHunkAction('rejected', { linesAdded: this.linesAdded, linesRemoved: this.linesRemoved, lineCount: this.lineChangeCount, hasRemainingEdits: false });326this.modifiedModel.pushStackElement();327this._applyEdits([(EditOperation.replace(this.modifiedModel.getFullModelRange(), this.originalModel.getValue()))], EditSources.chatUndoEdits());328this.modifiedModel.pushStackElement();329this._reset();330}331332private _reset() {333this._originalToModifiedEdit = StringEdit.empty;334this._diffInfo.set(nullDocumentDiff, undefined);335this._didUserEditModelFired = false;336}337338public async resetDocumentValues(newOriginal: string | ITextSnapshot | undefined, newModified: string | undefined): Promise<void> {339let didChange = false;340if (newOriginal !== undefined) {341this.originalModel.setValue(newOriginal);342didChange = true;343}344if (newModified !== undefined && this.modifiedModel.getValue() !== newModified) {345// NOTE that this isn't done via `setValue` so that the undo stack is preserved346this.modifiedModel.pushStackElement();347this._applyEdits([(EditOperation.replace(this.modifiedModel.getFullModelRange(), newModified))], EditSources.chatReset());348this.modifiedModel.pushStackElement();349didChange = true;350}351if (didChange) {352await this._updateDiffInfoSeq();353}354}355356private _mirrorEdits(event: IModelContentChangedEvent) {357const edit = offsetEditFromContentChanges(event.changes);358const isExternalEdit = this._isExternalEditInProgress?.();359360if (this._isEditFromUs || isExternalEdit) {361const e_sum = this._originalToModifiedEdit;362const e_ai = edit;363this._originalToModifiedEdit = e_sum.compose(e_ai);364if (isExternalEdit) {365this._updateDiffInfoSeq();366}367} else {368369// e_ai370// d0 ---------------> s0371// | |372// | |373// | e_user_r | e_user374// | |375// | |376// v e_ai_r v377/// d1 ---------------> s1378//379// d0 - document snapshot380// s0 - document381// e_ai - ai edits382// e_user - user edits383//384const e_ai = this._originalToModifiedEdit;385const e_user = edit;386387const e_user_r = e_user.tryRebase(e_ai.inverse(this.originalModel.getValue()));388389if (e_user_r === undefined) {390// user edits overlaps/conflicts with AI edits391this._originalToModifiedEdit = e_ai.compose(e_user);392} else {393const edits = offsetEditToEditOperations(e_user_r, this.originalModel);394this.originalModel.applyEdits(edits);395this._originalToModifiedEdit = e_ai.rebaseSkipConflicting(e_user_r);396}397398this._allEditsAreFromUs = false;399this._updateDiffInfoSeq();400if (!this._didUserEditModelFired) {401this._didUserEditModelFired = true;402this._didUserEditModel.fire();403}404}405}406407private async _keepHunk(change: DetailedLineRangeMapping): Promise<boolean> {408if (!this._diffInfo.get().changes.includes(change)) {409// diffInfo should have model version ids and check them (instead of the caller doing that)410return false;411}412const edits: ISingleEditOperation[] = [];413for (const edit of change.innerChanges ?? []) {414const newText = this.modifiedModel.getValueInRange(edit.modifiedRange);415edits.push(EditOperation.replace(edit.originalRange, newText));416}417this.originalModel.pushEditOperations(null, edits, _ => null);418await this._updateDiffInfoSeq('accepted');419if (this._diffInfo.get().identical) {420this._didAcceptOrRejectAllHunks.fire(ModifiedFileEntryState.Accepted);421}422this._accessibilitySignalService.playSignal(AccessibilitySignal.editsKept, { allowManyInParallel: true });423return true;424}425426private async _undoHunk(change: DetailedLineRangeMapping): Promise<boolean> {427if (!this._diffInfo.get().changes.includes(change)) {428return false;429}430const edits: ISingleEditOperation[] = [];431for (const edit of change.innerChanges ?? []) {432const newText = this.originalModel.getValueInRange(edit.originalRange);433edits.push(EditOperation.replace(edit.modifiedRange, newText));434}435this.modifiedModel.pushEditOperations(null, edits, _ => null);436await this._updateDiffInfoSeq('rejected');437if (this._diffInfo.get().identical) {438this._didAcceptOrRejectAllHunks.fire(ModifiedFileEntryState.Rejected);439}440this._accessibilitySignalService.playSignal(AccessibilitySignal.editsUndone, { allowManyInParallel: true });441return true;442}443444public async getDiffInfo() {445if (!this._diffOperation) {446this._updateDiffInfoSeq();447}448449await this._diffOperation;450return this._diffInfo.get();451}452453454private async _updateDiffInfoSeq(notifyAction: 'accepted' | 'rejected' | undefined = undefined) {455const myDiffOperationId = ++this._diffOperationIds;456await Promise.resolve(this._diffOperation);457const previousCount = this.lineChangeCount;458const previousAdded = this.linesAdded;459const previousRemoved = this.linesRemoved;460if (this._diffOperationIds === myDiffOperationId) {461const thisDiffOperation = this._updateDiffInfo();462this._diffOperation = thisDiffOperation;463await thisDiffOperation;464if (notifyAction) {465const affectedLines = {466linesAdded: previousAdded - this.linesAdded,467linesRemoved: previousRemoved - this.linesRemoved,468lineCount: previousCount - this.lineChangeCount,469hasRemainingEdits: this.lineChangeCount > 0470};471this.notifyHunkAction(notifyAction, affectedLines);472}473}474}475476public hasHunkAt(range: IRange) {477// return true if the range overlaps a diff range478return this._diffInfo.get().changes.some(c => c.modified.intersectsStrict(LineRange.fromRangeInclusive(range)));479}480481private async _updateDiffInfo(): Promise<IDocumentDiff | undefined> {482483if (this.originalModel.isDisposed() || this.modifiedModel.isDisposed() || this._store.isDisposed) {484return undefined;485}486487if (this.state.get() !== ModifiedFileEntryState.Modified) {488this._diffInfo.set(nullDocumentDiff, undefined);489this._originalToModifiedEdit = StringEdit.empty;490return nullDocumentDiff;491}492493const docVersionNow = this.modifiedModel.getVersionId();494const snapshotVersionNow = this.originalModel.getVersionId();495496const diff = await this._editorWorkerService.computeDiff(497this.originalModel.uri,498this.modifiedModel.uri,499{500ignoreTrimWhitespace: false, // NEVER ignore whitespace so that undo/accept edits are correct and so that all changes (1 of 2) are spelled out501computeMoves: false,502maxComputationTimeMs: 3000503},504'advanced'505);506507if (this.originalModel.isDisposed() || this.modifiedModel.isDisposed() || this._store.isDisposed) {508return undefined;509}510511// only update the diff if the documents didn't change in the meantime512if (this.modifiedModel.getVersionId() === docVersionNow && this.originalModel.getVersionId() === snapshotVersionNow) {513const diff2 = diff ?? nullDocumentDiff;514this._diffInfo.set(diff2, undefined);515this._originalToModifiedEdit = offsetEditFromLineRangeMapping(this.originalModel, this.modifiedModel, diff2.changes);516return diff2;517}518return undefined;519}520}521522523