From 488d6df7955ca02076fd1da3fb8cbdf2a252a407 Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Thu, 26 Jun 2025 10:52:51 +0200 Subject: [PATCH] Implements edit source tracking through proposed API (#252430) Implements edit source tracking through proposed API --- src/vs/editor/browser/editorBrowser.ts | 7 ++ .../widget/codeEditor/codeEditorWidget.ts | 13 +++- src/vs/editor/common/core/edits/textEdit.ts | 15 +++- src/vs/editor/common/cursor/cursor.ts | 46 ++++++++---- src/vs/editor/common/model.ts | 22 +++--- src/vs/editor/common/model/editStack.ts | 5 +- src/vs/editor/common/model/textModel.ts | 48 +++++++----- src/vs/editor/common/services/model.ts | 3 +- src/vs/editor/common/services/modelService.ts | 7 +- src/vs/editor/common/textModelEditReason.ts | 53 ++++++-------- src/vs/editor/common/textModelEvents.ts | 54 ++++++++++++++ .../editor/common/viewModel/viewModelImpl.ts | 5 +- .../browser/model/changeRecorder.ts | 44 ++++------- .../browser/model/inlineCompletionsModel.ts | 35 ++++----- .../browser/structuredLogger.ts | 1 - .../test/browser/controller/cursor.test.ts | 3 +- .../widget/observableCodeEditor.test.ts | 73 ++++++++++--------- src/vs/monaco.d.ts | 43 ++++++++++- .../common/extensionsApiProposals.ts | 3 + .../api/browser/mainThreadDocuments.ts | 19 ++++- .../workbench/api/common/extHost.api.impl.ts | 3 + .../workbench/api/common/extHost.protocol.ts | 4 +- .../workbench/api/common/extHostDocuments.ts | 46 ++++++++++-- .../extHostDocumentSaveParticipant.test.ts | 6 ++ .../common/editor/textEditorModel.ts | 5 +- .../chatEditingTextModelChangeService.ts | 12 +-- .../contrib/inlineChat/browser/utils.ts | 12 +-- .../textfile/common/textFileEditorModel.ts | 8 +- ...ode.proposed.textDocumentChangeReason.d.ts | 30 ++++++++ 29 files changed, 417 insertions(+), 208 deletions(-) create mode 100644 src/vscode-dts/vscode.proposed.textDocumentChangeReason.d.ts diff --git a/src/vs/editor/browser/editorBrowser.ts b/src/vs/editor/browser/editorBrowser.ts index d63387889fe..c6248ea6a97 100644 --- a/src/vs/editor/browser/editorBrowser.ts +++ b/src/vs/editor/browser/editorBrowser.ts @@ -25,6 +25,8 @@ import { OverviewRulerZone } from '../common/viewModel/overviewZoneManager.js'; import { MenuId } from '../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; +import { TextEdit } from '../common/core/edits/textEdit.js'; +import { TextModelEditReason } from '../common/textModelEditReason.js'; /** * A view zone is a full horizontal rectangle that 'pushes' text down. @@ -989,6 +991,11 @@ export interface ICodeEditor extends editorCommon.IEditor { */ executeEdits(source: string | null | undefined, edits: IIdentifiedSingleEditOperation[], endCursorState?: ICursorStateComputer | Selection[]): boolean; + /** + * @internal + */ + edit(edit: TextEdit, reason: TextModelEditReason): void; + /** * Execute multiple (concomitant) commands on the editor. * @param source The source of the call. diff --git a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts index dc5ee78a9da..f7de0e6a725 100644 --- a/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditor/codeEditorWidget.ts @@ -60,6 +60,8 @@ import { INotificationService, Severity } from '../../../../platform/notificatio import { editorErrorForeground, editorHintForeground, editorInfoForeground, editorWarningForeground } from '../../../../platform/theme/common/colorRegistry.js'; import { IThemeService, registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { TextModelEditReason } from '../../../common/textModelEditReason.js'; +import { TextEdit } from '../../../common/core/edits/textEdit.js'; export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeEditor { @@ -1239,7 +1241,11 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE return true; } - public executeEdits(source: string | null | undefined, edits: IIdentifiedSingleEditOperation[], endCursorState?: ICursorStateComputer | Selection[]): boolean { + public edit(edit: TextEdit, reason: TextModelEditReason): boolean { + return this.executeEdits(reason.metadata.source, edit.replacements.map(e => ({ range: e.range, text: e.text }))); + } + + public executeEdits(source: string | null | undefined, edits: IIdentifiedSingleEditOperation[], endCursorState?: ICursorStateComputer | Selection[], editReason?: TextModelEditReason): boolean { if (!this._modelData) { return false; } @@ -1259,7 +1265,10 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE this._onBeforeExecuteEdit.fire({ source: source ?? undefined }); - this._modelData.viewModel.executeEdits(source, edits, cursorStateComputer); + if (!editReason) { + editReason = source ? new TextModelEditReason({ source: source }) : TextModelEditReason.Unknown; + } + this._modelData.viewModel.executeEdits(source, edits, cursorStateComputer, editReason); return true; } diff --git a/src/vs/editor/common/core/edits/textEdit.ts b/src/vs/editor/common/core/edits/textEdit.ts index 2bb7d7a076c..2e1d490bc7e 100644 --- a/src/vs/editor/common/core/edits/textEdit.ts +++ b/src/vs/editor/common/core/edits/textEdit.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { equals } from '../../../../base/common/arrays.js'; +import { compareBy, equals } from '../../../../base/common/arrays.js'; import { assertFn, checkAdjacentItems } from '../../../../base/common/assert.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { commonPrefixLength, commonSuffixLength } from '../../../../base/common/strings.js'; @@ -24,10 +24,19 @@ export class TextEdit { return new TextEdit([new TextReplacement(originalRange, newText)]); } + public static delete(range: Range): TextEdit { + return new TextEdit([new TextReplacement(range, '')]); + } + public static insert(position: Position, newText: string): TextEdit { return new TextEdit([new TextReplacement(Range.fromPositions(position, position), newText)]); } + public static fromParallelReplacementsUnsorted(replacements: readonly TextReplacement[]): TextEdit { + const r = replacements.slice().sort(compareBy(i => i.range, Range.compareRangesUsingStarts)); + return new TextEdit(r); + } + constructor( public readonly replacements: readonly TextReplacement[] ) { @@ -285,6 +294,10 @@ export class TextReplacement { return new TextReplacement(initialState.getTransformer().getRange(replacement.replaceRange), replacement.newText); } + public static delete(range: Range): TextReplacement { + return new TextReplacement(range, ''); + } + constructor( public readonly range: Range, public readonly text: string, diff --git a/src/vs/editor/common/cursor/cursor.ts b/src/vs/editor/common/cursor/cursor.ts index a276dfb3548..aec40e52aa8 100644 --- a/src/vs/editor/common/cursor/cursor.ts +++ b/src/vs/editor/common/cursor/cursor.ts @@ -22,6 +22,7 @@ import { VerticalRevealType, ViewCursorStateChangedEvent, ViewRevealRangeRequest import { dispose, Disposable } from '../../../base/common/lifecycle.js'; import { ICoordinatesConverter } from '../viewModel.js'; import { CursorStateChangedEvent, ViewModelEventsCollector } from '../viewModelEventDispatcher.js'; +import { TextModelEditReason } from '../textModelEditReason.js'; export class CursorsController extends Disposable { @@ -346,7 +347,7 @@ export class CursorsController extends Disposable { this._autoClosedActions.push(new AutoClosedAction(this._model, autoClosedCharactersDecorations, autoClosedEnclosingDecorations)); } - private _executeEditOperation(opResult: EditOperationResult | null): void { + private _executeEditOperation(opResult: EditOperationResult | null, editReason: TextModelEditReason): void { if (!opResult) { // Nothing to execute @@ -357,7 +358,7 @@ export class CursorsController extends Disposable { this._model.pushStackElement(); } - const result = CommandExecutor.executeCommands(this._model, this._cursors.getSelections(), opResult.commands); + const result = CommandExecutor.executeCommands(this._model, this._cursors.getSelections(), opResult.commands, editReason); if (result) { // The commands were applied correctly this._interpretCommandResult(result); @@ -463,7 +464,7 @@ export class CursorsController extends Disposable { return indices; } - public executeEdits(eventsCollector: ViewModelEventsCollector, source: string | null | undefined, edits: IIdentifiedSingleEditOperation[], cursorStateComputer: ICursorStateComputer): void { + public executeEdits(eventsCollector: ViewModelEventsCollector, source: string | null | undefined, edits: IIdentifiedSingleEditOperation[], cursorStateComputer: ICursorStateComputer, reason: TextModelEditReason): void { let autoClosingIndices: [number, number][] | null = null; if (source === 'snippet') { autoClosingIndices = this._findAutoClosingPairs(edits); @@ -495,7 +496,7 @@ export class CursorsController extends Disposable { } return selections; - }); + }, undefined, reason); if (selections) { this._isHandling = false; this.setSelections(eventsCollector, source, selections, CursorChangeReason.NotSet); @@ -539,18 +540,22 @@ export class CursorsController extends Disposable { } public endComposition(eventsCollector: ViewModelEventsCollector, source?: string | null | undefined): void { + const reason = new TextModelEditReason({ source: 'cursor', kind: 'compositionEnd', detailedSource: source }); + const compositionOutcome = this._compositionState ? this._compositionState.deduceOutcome(this._model, this.getSelections()) : null; this._compositionState = null; this._executeEdit(() => { if (source === 'keyboard') { // composition finishes, let's check if we need to auto complete if necessary. - this._executeEditOperation(TypeOperations.compositionEndWithInterceptors(this._prevEditOperationType, this.context.cursorConfig, this._model, compositionOutcome, this.getSelections(), this.getAutoClosedCharacters())); + this._executeEditOperation(TypeOperations.compositionEndWithInterceptors(this._prevEditOperationType, this.context.cursorConfig, this._model, compositionOutcome, this.getSelections(), this.getAutoClosedCharacters()), reason); } }, eventsCollector, source); } public type(eventsCollector: ViewModelEventsCollector, text: string, source?: string | null | undefined): void { + const reason = new TextModelEditReason({ source: 'cursor', kind: 'type', detailedSource: source }); + this._executeEdit(() => { if (source === 'keyboard') { // If this event is coming straight from the keyboard, look for electric characters and enter @@ -562,18 +567,20 @@ export class CursorsController extends Disposable { const chr = text.substr(offset, charLength); // Here we must interpret each typed character individually - this._executeEditOperation(TypeOperations.typeWithInterceptors(!!this._compositionState, this._prevEditOperationType, this.context.cursorConfig, this._model, this.getSelections(), this.getAutoClosedCharacters(), chr)); + this._executeEditOperation(TypeOperations.typeWithInterceptors(!!this._compositionState, this._prevEditOperationType, this.context.cursorConfig, this._model, this.getSelections(), this.getAutoClosedCharacters(), chr), reason); offset += charLength; } } else { - this._executeEditOperation(TypeOperations.typeWithoutInterceptors(this._prevEditOperationType, this.context.cursorConfig, this._model, this.getSelections(), text)); + this._executeEditOperation(TypeOperations.typeWithoutInterceptors(this._prevEditOperationType, this.context.cursorConfig, this._model, this.getSelections(), text), reason); } }, eventsCollector, source); } public compositionType(eventsCollector: ViewModelEventsCollector, text: string, replacePrevCharCnt: number, replaceNextCharCnt: number, positionDelta: number, source?: string | null | undefined): void { + const reason = new TextModelEditReason({ source: 'cursor', kind: 'compositionType', detailedSource: source }); + if (text.length === 0 && replacePrevCharCnt === 0 && replaceNextCharCnt === 0) { // this edit is a no-op if (positionDelta !== 0) { @@ -587,39 +594,46 @@ export class CursorsController extends Disposable { return; } this._executeEdit(() => { - this._executeEditOperation(TypeOperations.compositionType(this._prevEditOperationType, this.context.cursorConfig, this._model, this.getSelections(), text, replacePrevCharCnt, replaceNextCharCnt, positionDelta)); + this._executeEditOperation(TypeOperations.compositionType(this._prevEditOperationType, this.context.cursorConfig, this._model, this.getSelections(), text, replacePrevCharCnt, replaceNextCharCnt, positionDelta), reason); }, eventsCollector, source); } public paste(eventsCollector: ViewModelEventsCollector, text: string, pasteOnNewLine: boolean, multicursorText?: string[] | null | undefined, source?: string | null | undefined): void { + const reason = new TextModelEditReason({ source: 'cursor', kind: 'paste', detailedSource: source }); + this._executeEdit(() => { - this._executeEditOperation(TypeOperations.paste(this.context.cursorConfig, this._model, this.getSelections(), text, pasteOnNewLine, multicursorText || [])); + this._executeEditOperation(TypeOperations.paste(this.context.cursorConfig, this._model, this.getSelections(), text, pasteOnNewLine, multicursorText || []), reason); }, eventsCollector, source, CursorChangeReason.Paste); } public cut(eventsCollector: ViewModelEventsCollector, source?: string | null | undefined): void { + const reason = new TextModelEditReason({ source: 'cursor', kind: 'cut', detailedSource: source }); this._executeEdit(() => { - this._executeEditOperation(DeleteOperations.cut(this.context.cursorConfig, this._model, this.getSelections())); + this._executeEditOperation(DeleteOperations.cut(this.context.cursorConfig, this._model, this.getSelections()), reason); }, eventsCollector, source); } public executeCommand(eventsCollector: ViewModelEventsCollector, command: editorCommon.ICommand, source?: string | null | undefined): void { + const reason = new TextModelEditReason({ source: 'cursor', kind: 'executeCommand', detailedSource: source }); + this._executeEdit(() => { this._cursors.killSecondaryCursors(); this._executeEditOperation(new EditOperationResult(EditOperationType.Other, [command], { shouldPushStackElementBefore: false, shouldPushStackElementAfter: false - })); + }), reason); }, eventsCollector, source); } public executeCommands(eventsCollector: ViewModelEventsCollector, commands: editorCommon.ICommand[], source?: string | null | undefined): void { + const reason = new TextModelEditReason({ source: 'cursor', kind: 'executeCommands', detailedSource: source }); + this._executeEdit(() => { this._executeEditOperation(new EditOperationResult(EditOperationType.Other, commands, { shouldPushStackElementBefore: false, shouldPushStackElementAfter: false - })); + }), reason); }, eventsCollector, source); } } @@ -742,7 +756,7 @@ interface ICommandsData { export class CommandExecutor { - public static executeCommands(model: ITextModel, selectionsBefore: Selection[], commands: (editorCommon.ICommand | null)[]): Selection[] | null { + public static executeCommands(model: ITextModel, selectionsBefore: Selection[], commands: (editorCommon.ICommand | null)[], editReason: TextModelEditReason = TextModelEditReason.Unknown): Selection[] | null { const ctx: IExecContext = { model: model, @@ -751,7 +765,7 @@ export class CommandExecutor { trackedRangesDirection: [] }; - const result = this._innerExecuteCommands(ctx, commands); + const result = this._innerExecuteCommands(ctx, commands, editReason); for (let i = 0, len = ctx.trackedRanges.length; i < len; i++) { ctx.model._setTrackedRange(ctx.trackedRanges[i], null, TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges); @@ -760,7 +774,7 @@ export class CommandExecutor { return result; } - private static _innerExecuteCommands(ctx: IExecContext, commands: (editorCommon.ICommand | null)[]): Selection[] | null { + private static _innerExecuteCommands(ctx: IExecContext, commands: (editorCommon.ICommand | null)[], editReason: TextModelEditReason): Selection[] | null { if (this._arrayIsEmpty(commands)) { return null; @@ -831,7 +845,7 @@ export class CommandExecutor { } } return cursorSelections; - }); + }, undefined, editReason); if (!selectionsAfter) { selectionsAfter = ctx.selectionsBefore; } diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index e0cdf27692b..6a869b84832 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -26,6 +26,7 @@ import { UndoRedoGroup } from '../../platform/undoRedo/common/undoRedo.js'; import { TokenArray } from './tokens/lineTokens.js'; import { IEditorModel } from './editorCommon.js'; import { TextModelEditReason } from './textModelEditReason.js'; +import { TextEdit } from './core/edits/textEdit.js'; /** * Vertical Lane in the overview ruler of the editor. @@ -1160,6 +1161,11 @@ export interface ITextModel { */ popStackElement(): void; + /** + * @internal + */ + edit(edit: TextEdit, options?: { reason?: TextModelEditReason }): void; + /** * Push edit operations, basically editing the model. This is the preferred way * of editing the model. The edit operations will land on the undo stack. @@ -1172,7 +1178,7 @@ export interface ITextModel { /** * @internal */ - pushEditOperations(beforeCursorState: Selection[] | null, editOperations: IIdentifiedSingleEditOperation[], cursorStateComputer: ICursorStateComputer, group?: UndoRedoGroup): Selection[] | null; + pushEditOperations(beforeCursorState: Selection[] | null, editOperations: IIdentifiedSingleEditOperation[], cursorStateComputer: ICursorStateComputer, group?: UndoRedoGroup, reason?: TextModelEditReason): Selection[] | null; /** * Change the end of line sequence. This is the preferred way of @@ -1186,9 +1192,11 @@ export interface ITextModel { * @param operations The edit operations. * @return If desired, the inverse edit operations, that, when applied, will bring the model back to the previous state. */ - applyEdits(operations: IIdentifiedSingleEditOperation[]): void; - applyEdits(operations: IIdentifiedSingleEditOperation[], computeUndoEdits: false): void; - applyEdits(operations: IIdentifiedSingleEditOperation[], computeUndoEdits: true): IValidEditOperation[]; + applyEdits(operations: readonly IIdentifiedSingleEditOperation[]): void; + /** @internal */ + applyEdits(operations: readonly IIdentifiedSingleEditOperation[], reason: TextModelEditReason): void; + applyEdits(operations: readonly IIdentifiedSingleEditOperation[], computeUndoEdits: false): void; + applyEdits(operations: readonly IIdentifiedSingleEditOperation[], computeUndoEdits: true): IValidEditOperation[]; /** * Change the end of line sequence without recording in the undo stack. @@ -1351,12 +1359,6 @@ export interface ITextModel { * @internal */ readonly tokenization: ITokenizationTextModelPart; - - /** - * Sets the reason for all text model edits done in the callback. - * @internal - */ - editWithReason(editReason: TextModelEditReason, cb: () => T): T; } /** diff --git a/src/vs/editor/common/model/editStack.ts b/src/vs/editor/common/model/editStack.ts index b7a5fe5378d..c5923b7fdc3 100644 --- a/src/vs/editor/common/model/editStack.ts +++ b/src/vs/editor/common/model/editStack.ts @@ -15,6 +15,7 @@ import * as buffer from '../../../base/common/buffer.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { basename } from '../../../base/common/resources.js'; import { ISingleEditOperation } from '../core/editOperation.js'; +import { TextModelEditReason } from '../textModelEditReason.js'; function uriGetComparisonKey(resource: URI): string { return resource.toString(); @@ -424,9 +425,9 @@ export class EditStack { editStackElement.append(this._model, [], getModelEOL(this._model), this._model.getAlternativeVersionId(), null); } - public pushEditOperation(beforeCursorState: Selection[] | null, editOperations: ISingleEditOperation[], cursorStateComputer: ICursorStateComputer | null, group?: UndoRedoGroup): Selection[] | null { + public pushEditOperation(beforeCursorState: Selection[] | null, editOperations: ISingleEditOperation[], cursorStateComputer: ICursorStateComputer | null, group?: UndoRedoGroup, reason: TextModelEditReason = TextModelEditReason.Unknown): Selection[] | null { const editStackElement = this._getOrCreateEditStackElement(beforeCursorState, group); - const inverseEditOperations = this._model.applyEdits(editOperations, true); + const inverseEditOperations = this._model.applyEdits(editOperations, true, reason); const afterCursorState = EditStack._computeCursorState(cursorStateComputer, inverseEditOperations); const textChanges = inverseEditOperations.map((op, index) => ({ index: index, textChange: op.textChange })); textChanges.sort((a, b) => { diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index ddcc455dde6..912da1e76a3 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -49,6 +49,7 @@ import { IUndoRedoService, ResourceEditStackSnapshot, UndoRedoGroup } from '../. import { TokenArray } from '../tokens/lineTokens.js'; import { SetWithKey } from '../../../base/common/collections.js'; import { TextModelEditReason } from '../textModelEditReason.js'; +import { TextEdit } from '../core/edits/textEdit.js'; export function createTextBufferFactory(text: string): model.ITextBufferFactory { const builder = new PieceTreeTextBufferBuilder(); @@ -450,7 +451,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati this._eventEmitter.fire(new InternalModelContentChangeEvent(rawChange, change)); } - public setValue(value: string | model.ITextSnapshot): void { + public setValue(value: string | model.ITextSnapshot, reason = TextModelEditReason.SetValue): void { this._assertNotDisposed(); if (value === null || value === undefined) { @@ -458,10 +459,10 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati } const { textBuffer, disposable } = createTextBuffer(value, this._options.defaultEOL); - this._setValueFromTextBuffer(textBuffer, disposable); + this._setValueFromTextBuffer(textBuffer, disposable, reason); } - private _createContentChanged2(range: Range, rangeOffset: number, rangeLength: number, rangeEndPosition: Position, text: string, isUndoing: boolean, isRedoing: boolean, isFlush: boolean, isEolChange: boolean): IModelContentChangedEvent { + private _createContentChanged2(range: Range, rangeOffset: number, rangeLength: number, rangeEndPosition: Position, text: string, isUndoing: boolean, isRedoing: boolean, isFlush: boolean, isEolChange: boolean, reason: TextModelEditReason): IModelContentChangedEvent { return { changes: [{ range: range, @@ -474,11 +475,13 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati versionId: this.getVersionId(), isUndoing: isUndoing, isRedoing: isRedoing, - isFlush: isFlush + isFlush: isFlush, + detailedReasons: [reason], + detailedReasonsChangeLengths: [1], }; } - private _setValueFromTextBuffer(textBuffer: model.ITextBuffer, textBufferDisposable: IDisposable): void { + private _setValueFromTextBuffer(textBuffer: model.ITextBuffer, textBufferDisposable: IDisposable, reason: TextModelEditReason): void { this._assertNotDisposed(); const oldFullModelRange = this.getFullModelRange(); const oldModelValueLength = this.getValueLengthInRange(oldFullModelRange); @@ -507,7 +510,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati false, false ), - this._createContentChanged2(new Range(1, 1, endLineNumber, endColumn), 0, oldModelValueLength, new Position(endLineNumber, endColumn), this.getValue(), false, false, true, false) + this._createContentChanged2(new Range(1, 1, endLineNumber, endColumn), 0, oldModelValueLength, new Position(endLineNumber, endColumn), this.getValue(), false, false, true, false, reason) ); } @@ -538,7 +541,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati false, false ), - this._createContentChanged2(new Range(1, 1, endLineNumber, endColumn), 0, oldModelValueLength, new Position(endLineNumber, endColumn), this.getValue(), false, false, false, true) + this._createContentChanged2(new Range(1, 1, endLineNumber, endColumn), 0, oldModelValueLength, new Position(endLineNumber, endColumn), this.getValue(), false, false, false, true, TextModelEditReason.EolChange) ); } @@ -1281,18 +1284,22 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati return result; } - public pushEditOperations(beforeCursorState: Selection[] | null, editOperations: model.IIdentifiedSingleEditOperation[], cursorStateComputer: model.ICursorStateComputer | null, group?: UndoRedoGroup): Selection[] | null { + public edit(edit: TextEdit, options?: { reason?: TextModelEditReason }): void { + this.pushEditOperations(null, edit.replacements.map(r => ({ range: r.range, text: r.text })), null); + } + + public pushEditOperations(beforeCursorState: Selection[] | null, editOperations: model.IIdentifiedSingleEditOperation[], cursorStateComputer: model.ICursorStateComputer | null, group?: UndoRedoGroup, reason?: TextModelEditReason): Selection[] | null { try { this._onDidChangeDecorations.beginDeferredEmit(); this._eventEmitter.beginDeferredEmit(); - return this._pushEditOperations(beforeCursorState, this._validateEditOperations(editOperations), cursorStateComputer, group); + return this._pushEditOperations(beforeCursorState, this._validateEditOperations(editOperations), cursorStateComputer, group, reason); } finally { this._eventEmitter.endDeferredEmit(); this._onDidChangeDecorations.endDeferredEmit(); } } - private _pushEditOperations(beforeCursorState: Selection[] | null, editOperations: model.ValidAnnotatedEditOperation[], cursorStateComputer: model.ICursorStateComputer | null, group?: UndoRedoGroup): Selection[] | null { + private _pushEditOperations(beforeCursorState: Selection[] | null, editOperations: model.ValidAnnotatedEditOperation[], cursorStateComputer: model.ICursorStateComputer | null, group?: UndoRedoGroup, reason?: TextModelEditReason): Selection[] | null { if (this._options.trimAutoWhitespace && this._trimAutoWhitespaceLines) { // Go through each saved line number and insert a trim whitespace edit // if it is safe to do so (no conflicts with other edits). @@ -1379,7 +1386,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati if (this._initialUndoRedoSnapshot === null) { this._initialUndoRedoSnapshot = this._undoRedoService.createSnapshot(this.uri); } - return this._commandManager.pushEditOperation(beforeCursorState, editOperations, cursorStateComputer, group); + return this._commandManager.pushEditOperation(beforeCursorState, editOperations, cursorStateComputer, group, reason); } _applyUndo(changes: TextChange[], eol: model.EndOfLineSequence, resultingAlternativeVersionId: number, resultingSelection: Selection[] | null): void { @@ -1426,19 +1433,24 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati public applyEdits(operations: readonly model.IIdentifiedSingleEditOperation[]): void; public applyEdits(operations: readonly model.IIdentifiedSingleEditOperation[], computeUndoEdits: false): void; public applyEdits(operations: readonly model.IIdentifiedSingleEditOperation[], computeUndoEdits: true): model.IValidEditOperation[]; - public applyEdits(rawOperations: readonly model.IIdentifiedSingleEditOperation[], computeUndoEdits: boolean = false): void | model.IValidEditOperation[] { + /** @internal */ + public applyEdits(operations: readonly model.IIdentifiedSingleEditOperation[], computeUndoEdits: false, reason: TextModelEditReason): void; + /** @internal */ + public applyEdits(operations: readonly model.IIdentifiedSingleEditOperation[], computeUndoEdits: true, reason: TextModelEditReason): model.IValidEditOperation[]; + public applyEdits(rawOperations: readonly model.IIdentifiedSingleEditOperation[], computeUndoEdits?: boolean, reason?: TextModelEditReason): void | model.IValidEditOperation[] { try { this._onDidChangeDecorations.beginDeferredEmit(); this._eventEmitter.beginDeferredEmit(); const operations = this._validateEditOperations(rawOperations); - return this._doApplyEdits(operations, computeUndoEdits); + + return this._doApplyEdits(operations, computeUndoEdits ?? false, reason ?? TextModelEditReason.ApplyEdits); } finally { this._eventEmitter.endDeferredEmit(); this._onDidChangeDecorations.endDeferredEmit(); } } - private _doApplyEdits(rawOperations: model.ValidAnnotatedEditOperation[], computeUndoEdits: boolean): void | model.IValidEditOperation[] { + private _doApplyEdits(rawOperations: model.ValidAnnotatedEditOperation[], computeUndoEdits: boolean, reason: TextModelEditReason): void | model.IValidEditOperation[] { const oldLineCount = this._buffer.getLineCount(); const result = this._buffer.applyEdits(rawOperations, this._options.trimAutoWhitespace, computeUndoEdits); @@ -1555,7 +1567,9 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati versionId: this.getVersionId(), isUndoing: this._isUndoing, isRedoing: this._isRedoing, - isFlush: false + isFlush: false, + detailedReasons: [reason], + detailedReasonsChangeLengths: [contentChanges.length], } ); } @@ -2027,10 +2041,6 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati public override toString(): string { return `TextModel(${this.uri.toString()})`; } - - editWithReason(editReason: TextModelEditReason, cb: () => T): T { - return TextModelEditReason.editWithReason(editReason, cb); - } } export function indentOfLine(line: string): number { diff --git a/src/vs/editor/common/services/model.ts b/src/vs/editor/common/services/model.ts index 4bad6f50e4e..e4555e656ab 100644 --- a/src/vs/editor/common/services/model.ts +++ b/src/vs/editor/common/services/model.ts @@ -9,6 +9,7 @@ import { ITextBufferFactory, ITextModel, ITextModelCreationOptions } from '../mo import { ILanguageSelection } from '../languages/language.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { DocumentSemanticTokensProvider, DocumentRangeSemanticTokensProvider } from '../languages.js'; +import { TextModelEditReason } from '../textModelEditReason.js'; export const IModelService = createDecorator('modelService'); @@ -19,7 +20,7 @@ export interface IModelService { createModel(value: string | ITextBufferFactory, languageSelection: ILanguageSelection | null, resource?: URI, isForSimpleWidget?: boolean): ITextModel; - updateModel(model: ITextModel, value: string | ITextBufferFactory): void; + updateModel(model: ITextModel, value: string | ITextBufferFactory, reason?: TextModelEditReason): void; destroyModel(resource: URI): void; diff --git a/src/vs/editor/common/services/modelService.ts b/src/vs/editor/common/services/modelService.ts index eefda2797b9..ffc46bbaf2f 100644 --- a/src/vs/editor/common/services/modelService.ts +++ b/src/vs/editor/common/services/modelService.ts @@ -24,6 +24,7 @@ import { isEditStackElement } from '../model/editStack.js'; import { Schemas } from '../../../base/common/network.js'; import { equals } from '../../../base/common/objects.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { TextModelEditReason } from '../textModelEditReason.js'; function MODEL_ID(resource: URI): string { return resource.toString(); @@ -368,7 +369,7 @@ export class ModelService extends Disposable implements IModelService { return modelData; } - public updateModel(model: ITextModel, value: string | ITextBufferFactory): void { + public updateModel(model: ITextModel, value: string | ITextBufferFactory, reason: TextModelEditReason = TextModelEditReason.Unknown): void { const options = this.getCreationOptions(model.getLanguageId(), model.uri, model.isForSimpleWidget); const { textBuffer, disposable } = createTextBuffer(value, options.defaultEOL); @@ -384,7 +385,9 @@ export class ModelService extends Disposable implements IModelService { model.pushEditOperations( [], ModelService._computeEdits(model, textBuffer), - () => [] + () => [], + undefined, + reason ); model.pushStackElement(); disposable.dispose(); diff --git a/src/vs/editor/common/textModelEditReason.ts b/src/vs/editor/common/textModelEditReason.ts index adf4a7f826a..1b7ed6b4be3 100644 --- a/src/vs/editor/common/textModelEditReason.ts +++ b/src/vs/editor/common/textModelEditReason.ts @@ -4,38 +4,29 @@ *--------------------------------------------------------------------------------------------*/ export class TextModelEditReason { - private static _nextMetadataId = 0; - private static _metaDataMap = new Map(); - - /** - * Sets the reason for all text model edits done in the callback. - */ - public static editWithReason(reason: TextModelEditReason, runner: () => T): T { - const id = this._nextMetadataId++; - this._metaDataMap.set(id, reason.metadata); - try { - const result = runner(); - return result; - } finally { - this._metaDataMap.delete(id); - } - } - - public static _getCurrentMetadata(): ITextModelEditReasonMetadata { - const result: ITextModelEditReasonMetadata = {}; - for (const metadata of this._metaDataMap.values()) { - Object.assign(result, metadata); - } - return result; - } + public static readonly EolChange = new TextModelEditReason({ source: 'eolChange' }); + public static readonly SetValue = new TextModelEditReason({ source: 'setValue' }); + public static readonly ApplyEdits = new TextModelEditReason({ source: 'applyEdits' }); + public static readonly Unknown = new TextModelEditReason({ source: 'unknown' }); + public static readonly Type = new TextModelEditReason({ source: 'type' }); constructor(public readonly metadata: ITextModelEditReasonMetadata) { } + + public toString(): string { + return `${this.metadata.source}`; + } } -interface ITextModelEditReasonMetadata { - source?: 'Chat.applyEdits' | 'inlineChat.applyEdit' | 'reloadFromDisk'; - extensionId?: string; - nes?: boolean; - type?: 'word' | 'line'; - requestUuid?: string; -} +export type ITextModelEditReasonMetadata = { + source: 'unknown' | 'Chat.applyEdits' | 'inlineChat.applyEdit' | 'reloadFromDisk' | 'eolChange' | 'setValue' | 'applyEdits' | string; +} | { + source: 'inlineCompletionAccept'; + nes: boolean; + type: 'word' | 'line' | undefined; + requestUuid: string; + extensionId: string | undefined; +} | { + source: 'cursor'; + kind: 'compositionType' | 'compositionEnd' | 'type' | 'paste' | 'cut' | 'executeCommands' | 'executeCommand'; + detailedSource?: string | null | undefined; +}; diff --git a/src/vs/editor/common/textModelEvents.ts b/src/vs/editor/common/textModelEvents.ts index 8f9ab67df70..33b2578727d 100644 --- a/src/vs/editor/common/textModelEvents.ts +++ b/src/vs/editor/common/textModelEvents.ts @@ -7,6 +7,7 @@ import { IPosition } from './core/position.js'; import { IRange, Range } from './core/range.js'; import { Selection } from './core/selection.js'; import { IModelDecoration, InjectedTextOptions } from './model.js'; +import { ITextModelEditReasonMetadata, TextModelEditReason } from './textModelEditReason.js'; /** * An event describing that the current language associated with a model has changed. @@ -86,6 +87,57 @@ export interface IModelContentChangedEvent { * Flag that indicates that this event describes an eol change. */ readonly isEolChange: boolean; + + /** + * Detailed reason information for the change + * @internal + */ + readonly detailedReasons: TextModelEditReason[]; + + /** + * The sum of these lengths equals changes.length. + * The length of this array must equal the length of detailedReasons. + */ + readonly detailedReasonsChangeLengths: number[]; +} + +export interface ISerializedModelContentChangedEvent { + /** + * The changes are ordered from the end of the document to the beginning, so they should be safe to apply in sequence. + */ + readonly changes: IModelContentChange[]; + /** + * The (new) end-of-line character. + */ + readonly eol: string; + /** + * The new version id the model has transitioned to. + */ + readonly versionId: number; + /** + * Flag that indicates that this event was generated while undoing. + */ + readonly isUndoing: boolean; + /** + * Flag that indicates that this event was generated while redoing. + */ + readonly isRedoing: boolean; + /** + * Flag that indicates that all decorations were lost with this edit. + * The model has been reset to a new value. + */ + readonly isFlush: boolean; + + /** + * Flag that indicates that this event describes an eol change. + */ + readonly isEolChange: boolean; + + /** + * Detailed reason information for the change + * @internal + */ + readonly detailedReason: ITextModelEditReasonMetadata | undefined; } /** @@ -455,6 +507,8 @@ export class InternalModelContentChangeEvent { isUndoing: isUndoing, isRedoing: isRedoing, isFlush: isFlush, + detailedReasons: a.detailedReasons.concat(b.detailedReasons), + detailedReasonsChangeLengths: a.detailedReasonsChangeLengths.concat(b.detailedReasonsChangeLengths), }; } } diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 45d600a8dbd..27d59e57dee 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -41,6 +41,7 @@ import { IViewModelLines, ViewModelLinesFromModelAsIs, ViewModelLinesFromProject import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { GlyphMarginLanesModel } from './glyphLanesModel.js'; import { ICustomLineHeightData } from '../viewLayout/lineHeights.js'; +import { TextModelEditReason } from '../textModelEditReason.js'; const USE_IDENTITY_LINES_COLLECTION = true; @@ -1096,8 +1097,8 @@ export class ViewModel extends Disposable implements IViewModel { } this._withViewEventsCollector(callback); } - public executeEdits(source: string | null | undefined, edits: IIdentifiedSingleEditOperation[], cursorStateComputer: ICursorStateComputer): void { - this._executeCursorEdit(eventsCollector => this._cursor.executeEdits(eventsCollector, source, edits, cursorStateComputer)); + public executeEdits(source: string | null | undefined, edits: IIdentifiedSingleEditOperation[], cursorStateComputer: ICursorStateComputer, reason: TextModelEditReason): void { + this._executeCursorEdit(eventsCollector => this._cursor.executeEdits(eventsCollector, source, edits, cursorStateComputer, reason)); } public startComposition(): void { this._executeCursorEdit(eventsCollector => this._cursor.startComposition(eventsCollector)); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/changeRecorder.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/changeRecorder.ts index 9dee6faeb9c..746414d99e7 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/changeRecorder.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/changeRecorder.ts @@ -8,7 +8,6 @@ import { autorunWithStore } from '../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ICodeEditor } from '../../../../browser/editorBrowser.js'; import { CodeEditorWidget } from '../../../../browser/widget/codeEditor/codeEditorWidget.js'; -import { TextModelEditReason } from '../../../../common/textModelEditReason.js'; import { IDocumentEventDataSetChangeReason, IRecordableEditorLogEntry, StructuredLogger } from '../structuredLogger.js'; export interface ITextModelChangeRecorderMetadata { @@ -33,38 +32,25 @@ export class TextModelChangeRecorder extends Disposable { if (!(this._editor instanceof CodeEditorWidget)) { return; } if (!this._structuredLogger.isEnabled.read(reader)) { return; } - const sources: string[] = []; - - store.add(this._editor.onBeforeExecuteEdit(({ source }) => { - if (source) { - sources.push(source); - } - })); - store.add(this._editor.onDidChangeModelContent(e => { const tm = this._editor.getModel(); if (!tm) { return; } - const metadata = TextModelEditReason._getCurrentMetadata(); - if (sources.length === 0 && metadata.source) { - sources.push(metadata.source); - } - for (const source of sources) { - const data: IRecordableEditorLogEntry & IDocumentEventDataSetChangeReason = { - ...metadata, - sourceId: 'TextModel.setChangeReason', - source: source, - time: Date.now(), - modelUri: tm.uri, - modelVersion: tm.getVersionId(), - }; - setTimeout(() => { - // To ensure that this reaches the extension host after the content change event. - // (Without the setTimeout, I observed this command being called before the content change event arrived) - this._structuredLogger.log(data); - }, 0); - } - sources.length = 0; + const reason = e.detailedReasons[0]; + + const data: IRecordableEditorLogEntry & IDocumentEventDataSetChangeReason = { + ...reason.metadata, + sourceId: 'TextModel.setChangeReason', + source: reason.metadata.source, + time: Date.now(), + modelUri: tm.uri, + modelVersion: tm.getVersionId(), + }; + setTimeout(() => { + // To ensure that this reaches the extension host after the content change event. + // (Without the setTimeout, I observed this command being called before the content change event arrived) + this._structuredLogger.log(data); + }, 0); })); })); } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts index 0d230d2f640..e1ac490ba61 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -18,7 +18,6 @@ import { ICodeEditor } from '../../../../browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../browser/observableCodeEditor.js'; import { EditorOption } from '../../../../common/config/editorOptions.js'; import { CursorColumns } from '../../../../common/core/cursorColumns.js'; -import { EditOperation } from '../../../../common/core/editOperation.js'; import { LineRange } from '../../../../common/core/ranges/lineRange.js'; import { Position } from '../../../../common/core/position.js'; import { Range } from '../../../../common/core/range.js'; @@ -776,6 +775,7 @@ export class InlineCompletionsModel extends Disposable { private _getMetadata(completion: InlineSuggestionItem, type: 'word' | 'line' | undefined = undefined): TextModelEditReason { return new TextModelEditReason({ + source: 'inlineCompletionAccept', extensionId: completion.source.provider.groupId, nes: completion.isInlineEdit, type, @@ -808,15 +808,11 @@ export class InlineCompletionsModel extends Disposable { try { editor.pushUndoStop(); if (completion.snippetInfo) { - TextModelEditReason.editWithReason(this._getMetadata(completion), () => { - editor.executeEdits( - 'inlineSuggestion.accept', - [ - EditOperation.replace(completion.editRange, ''), - ...completion.additionalTextEdits - ] - ); - }); + const mainEdit = TextReplacement.delete(completion.editRange); + const additionalEdits = completion.additionalTextEdits.map(e => new TextReplacement(Range.lift(e.range), e.text ?? '')); + const edit = TextEdit.fromParallelReplacementsUnsorted([mainEdit, ...additionalEdits]); + editor.edit(edit, this._getMetadata(completion)); + editor.setPosition(completion.snippetInfo.range.getStartPosition(), 'inlineCompletionAccept'); SnippetController2.get(editor)?.insert(completion.snippetInfo.snippet, { undoStopBefore: false }); } else { @@ -831,20 +827,18 @@ export class InlineCompletionsModel extends Disposable { } const selections = getEndPositionsAfterApplying(minimalEdits).map(p => Selection.fromPositions(p)); - TextModelEditReason.editWithReason(this._getMetadata(completion), () => { - editor.executeEdits('inlineSuggestion.accept', [ - ...edits.map(edit => EditOperation.replace(edit.range, edit.text)), - ...completion.additionalTextEdits - ]); - }); + const additionalEdits = completion.additionalTextEdits.map(e => new TextReplacement(Range.lift(e.range), e.text ?? '')); + const edit = TextEdit.fromParallelReplacementsUnsorted([...edits, ...additionalEdits]); + + editor.edit(edit, this._getMetadata(completion)); + if (completion.displayLocation === undefined) { // do not move the cursor when the completion is displayed in a different location editor.setSelections(state.kind === 'inlineEdit' ? selections.slice(-1) : selections, 'inlineCompletionAccept'); } if (state.kind === 'inlineEdit' && !this._accessibilityService.isMotionReduced()) { - // we can assume that edits is sorted! - const editRanges = new TextEdit(edits).getNewRanges(); + const editRanges = edit.getNewRanges(); const dec = this._store.add(new FadeoutDecoration(editor, editRanges, () => { this._store.delete(dec); })); @@ -951,9 +945,8 @@ export class InlineCompletionsModel extends Disposable { const primaryEdit = new TextReplacement(replaceRange, newText); const edits = [primaryEdit, ...getSecondaryEdits(this.textModel, positions, primaryEdit)]; const selections = getEndPositionsAfterApplying(edits).map(p => Selection.fromPositions(p)); - TextModelEditReason.editWithReason(this._getMetadata(completion, type), () => { - editor.executeEdits('inlineSuggestion.accept', edits.map(edit => EditOperation.replace(edit.range, edit.text))); - }); + + editor.edit(TextEdit.fromParallelReplacementsUnsorted(edits), this._getMetadata(completion, type)); editor.setSelections(selections, 'inlineCompletionPartialAccept'); editor.revealPositionInCenterIfOutsideViewport(editor.getPosition()!, ScrollType.Immediate); } finally { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/structuredLogger.ts b/src/vs/editor/contrib/inlineCompletions/browser/structuredLogger.ts index f7b79676b70..9bec197979e 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/structuredLogger.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/structuredLogger.ts @@ -25,7 +25,6 @@ export type LogEntryData = IEventFetchEnd; export interface IDocumentEventDataSetChangeReason { sourceId: 'TextModel.setChangeReason'; source: 'inlineSuggestion.accept' | 'snippet' | string; - detailedSource?: string; } interface IDocumentEventFetchStart { diff --git a/src/vs/editor/test/browser/controller/cursor.test.ts b/src/vs/editor/test/browser/controller/cursor.test.ts index fe7c5e59c46..da1646ccb5e 100644 --- a/src/vs/editor/test/browser/controller/cursor.test.ts +++ b/src/vs/editor/test/browser/controller/cursor.test.ts @@ -29,6 +29,7 @@ import { ITestCodeEditor, TestCodeEditorInstantiationOptions, createCodeEditorSe import { IRelaxedTextModelCreationOptions, createTextModel, instantiateTextModel } from '../../common/testTextModel.js'; import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { InputMode } from '../../../common/inputMode.js'; +import { TextModelEditReason } from '../../../common/textModelEditReason.js'; // --------- utils @@ -5650,7 +5651,7 @@ suite('Editor Controller', () => { }, (editor, model, viewModel) => { viewModel.setSelections('test', [new Selection(1, 8, 1, 8)]); - viewModel.executeEdits('snippet', [{ range: new Range(1, 6, 1, 8), text: 'id=""' }], () => [new Selection(1, 10, 1, 10)]); + viewModel.executeEdits('snippet', [{ range: new Range(1, 6, 1, 8), text: 'id=""' }], () => [new Selection(1, 10, 1, 10)], TextModelEditReason.Unknown); assert.strictEqual(model.getLineContent(1), '
{ createChangeSummary: () => undefined, handleChange: (context) => { const obsName = observableName(context.changedObservable, obsEditor); + log.log(`handle change: ${obsName} ${formatChange(context.change)}`); return true; }, @@ -72,45 +73,45 @@ suite("CodeEditorWidget", () => { withTestFixture(({ editor, log }) => { editor.setPosition(new Position(1, 2)); - assert.deepStrictEqual(log.getAndClearEntries(), [ - 'handle change: editor.selections {"selection":"[1,2 -> 1,2]","modelVersionId":1,"oldSelections":["[1,1 -> 1,1]"],"oldModelVersionId":1,"source":"api","reason":0}', - "running derived: selection: [1,2 -> 1,2], value: 1", - ]); + assert.deepStrictEqual(log.getAndClearEntries(), ([ + "handle change: editor.selections {\"selection\":\"[1,2 -> 1,2]\",\"modelVersionId\":1,\"oldSelections\":[\"[1,1 -> 1,1]\"],\"oldModelVersionId\":1,\"source\":\"api\",\"reason\":0}", + "running derived: selection: [1,2 -> 1,2], value: 1" + ])); })); test("keyboard.type", () => withTestFixture(({ editor, log }) => { editor.trigger("keyboard", "type", { text: "abc" }); - assert.deepStrictEqual(log.getAndClearEntries(), [ - 'handle change: editor.onDidType "abc"', - 'handle change: editor.versionId {"changes":[{"range":"[1,1 -> 1,1]","rangeLength":0,"text":"a","rangeOffset":0}],"eol":"\\n","versionId":2}', - 'handle change: editor.versionId {"changes":[{"range":"[1,2 -> 1,2]","rangeLength":0,"text":"b","rangeOffset":1}],"eol":"\\n","versionId":3}', - 'handle change: editor.versionId {"changes":[{"range":"[1,3 -> 1,3]","rangeLength":0,"text":"c","rangeOffset":2}],"eol":"\\n","versionId":4}', - 'handle change: editor.selections {"selection":"[1,4 -> 1,4]","modelVersionId":4,"oldSelections":["[1,1 -> 1,1]"],"oldModelVersionId":1,"source":"keyboard","reason":0}', - 'running derived: selection: [1,4 -> 1,4], value: 4', - ]); + assert.deepStrictEqual(log.getAndClearEntries(), ([ + "handle change: editor.onDidType \"abc\"", + "handle change: editor.versionId {\"changes\":[{\"range\":\"[1,1 -> 1,1]\",\"rangeLength\":0,\"text\":\"a\",\"rangeOffset\":0}],\"eol\":\"\\n\",\"versionId\":2,\"detailedReasons\":[{\"metadata\":{\"source\":\"cursor\",\"kind\":\"type\",\"detailedSource\":\"keyboard\"}}],\"detailedReasonsChangeLengths\":[1]}", + "handle change: editor.versionId {\"changes\":[{\"range\":\"[1,2 -> 1,2]\",\"rangeLength\":0,\"text\":\"b\",\"rangeOffset\":1}],\"eol\":\"\\n\",\"versionId\":3,\"detailedReasons\":[{\"metadata\":{\"source\":\"cursor\",\"kind\":\"type\",\"detailedSource\":\"keyboard\"}}],\"detailedReasonsChangeLengths\":[1]}", + "handle change: editor.versionId {\"changes\":[{\"range\":\"[1,3 -> 1,3]\",\"rangeLength\":0,\"text\":\"c\",\"rangeOffset\":2}],\"eol\":\"\\n\",\"versionId\":4,\"detailedReasons\":[{\"metadata\":{\"source\":\"cursor\",\"kind\":\"type\",\"detailedSource\":\"keyboard\"}}],\"detailedReasonsChangeLengths\":[1]}", + "handle change: editor.selections {\"selection\":\"[1,4 -> 1,4]\",\"modelVersionId\":4,\"oldSelections\":[\"[1,1 -> 1,1]\"],\"oldModelVersionId\":1,\"source\":\"keyboard\",\"reason\":0}", + "running derived: selection: [1,4 -> 1,4], value: 4" + ])); })); test("keyboard.type and set position", () => withTestFixture(({ editor, log }) => { editor.trigger("keyboard", "type", { text: "abc" }); - assert.deepStrictEqual(log.getAndClearEntries(), [ - 'handle change: editor.onDidType "abc"', - 'handle change: editor.versionId {"changes":[{"range":"[1,1 -> 1,1]","rangeLength":0,"text":"a","rangeOffset":0}],"eol":"\\n","versionId":2}', - 'handle change: editor.versionId {"changes":[{"range":"[1,2 -> 1,2]","rangeLength":0,"text":"b","rangeOffset":1}],"eol":"\\n","versionId":3}', - 'handle change: editor.versionId {"changes":[{"range":"[1,3 -> 1,3]","rangeLength":0,"text":"c","rangeOffset":2}],"eol":"\\n","versionId":4}', - 'handle change: editor.selections {"selection":"[1,4 -> 1,4]","modelVersionId":4,"oldSelections":["[1,1 -> 1,1]"],"oldModelVersionId":1,"source":"keyboard","reason":0}', - 'running derived: selection: [1,4 -> 1,4], value: 4', - ]); + assert.deepStrictEqual(log.getAndClearEntries(), ([ + "handle change: editor.onDidType \"abc\"", + "handle change: editor.versionId {\"changes\":[{\"range\":\"[1,1 -> 1,1]\",\"rangeLength\":0,\"text\":\"a\",\"rangeOffset\":0}],\"eol\":\"\\n\",\"versionId\":2,\"detailedReasons\":[{\"metadata\":{\"source\":\"cursor\",\"kind\":\"type\",\"detailedSource\":\"keyboard\"}}],\"detailedReasonsChangeLengths\":[1]}", + "handle change: editor.versionId {\"changes\":[{\"range\":\"[1,2 -> 1,2]\",\"rangeLength\":0,\"text\":\"b\",\"rangeOffset\":1}],\"eol\":\"\\n\",\"versionId\":3,\"detailedReasons\":[{\"metadata\":{\"source\":\"cursor\",\"kind\":\"type\",\"detailedSource\":\"keyboard\"}}],\"detailedReasonsChangeLengths\":[1]}", + "handle change: editor.versionId {\"changes\":[{\"range\":\"[1,3 -> 1,3]\",\"rangeLength\":0,\"text\":\"c\",\"rangeOffset\":2}],\"eol\":\"\\n\",\"versionId\":4,\"detailedReasons\":[{\"metadata\":{\"source\":\"cursor\",\"kind\":\"type\",\"detailedSource\":\"keyboard\"}}],\"detailedReasonsChangeLengths\":[1]}", + "handle change: editor.selections {\"selection\":\"[1,4 -> 1,4]\",\"modelVersionId\":4,\"oldSelections\":[\"[1,1 -> 1,1]\"],\"oldModelVersionId\":1,\"source\":\"keyboard\",\"reason\":0}", + "running derived: selection: [1,4 -> 1,4], value: 4" + ])); editor.setPosition(new Position(1, 5), "test"); - assert.deepStrictEqual(log.getAndClearEntries(), [ - 'handle change: editor.selections {"selection":"[1,5 -> 1,5]","modelVersionId":4,"oldSelections":["[1,4 -> 1,4]"],"oldModelVersionId":4,"source":"test","reason":0}', - "running derived: selection: [1,5 -> 1,5], value: 4", - ]); + assert.deepStrictEqual(log.getAndClearEntries(), ([ + "handle change: editor.selections {\"selection\":\"[1,5 -> 1,5]\",\"modelVersionId\":4,\"oldSelections\":[\"[1,4 -> 1,4]\"],\"oldModelVersionId\":4,\"source\":\"test\",\"reason\":0}", + "running derived: selection: [1,5 -> 1,5], value: 4" + ])); })); test("listener interaction (unforced)", () => { @@ -132,14 +133,14 @@ suite("CodeEditorWidget", () => { log = args.log; editor.trigger("keyboard", "type", { text: "a" }); - assert.deepStrictEqual(log.getAndClearEntries(), [ + assert.deepStrictEqual(log.getAndClearEntries(), ([ ">>> before get", "<<< after get", - 'handle change: editor.onDidType "a"', - 'handle change: editor.versionId {"changes":[{"range":"[1,1 -> 1,1]","rangeLength":0,"text":"a","rangeOffset":0}],"eol":"\\n","versionId":2}', - 'handle change: editor.selections {"selection":"[1,2 -> 1,2]","modelVersionId":2,"oldSelections":["[1,1 -> 1,1]"],"oldModelVersionId":1,"source":"keyboard","reason":0}', - "running derived: selection: [1,2 -> 1,2], value: 2", - ]); + "handle change: editor.onDidType \"a\"", + "handle change: editor.versionId {\"changes\":[{\"range\":\"[1,1 -> 1,1]\",\"rangeLength\":0,\"text\":\"a\",\"rangeOffset\":0}],\"eol\":\"\\n\",\"versionId\":2,\"detailedReasons\":[{\"metadata\":{\"source\":\"cursor\",\"kind\":\"type\",\"detailedSource\":\"keyboard\"}}],\"detailedReasonsChangeLengths\":[1]}", + "handle change: editor.selections {\"selection\":\"[1,2 -> 1,2]\",\"modelVersionId\":2,\"oldSelections\":[\"[1,1 -> 1,1]\"],\"oldModelVersionId\":1,\"source\":\"keyboard\",\"reason\":0}", + "running derived: selection: [1,2 -> 1,2], value: 2" + ])); } ); }); @@ -167,17 +168,17 @@ suite("CodeEditorWidget", () => { editor.trigger("keyboard", "type", { text: "a" }); - assert.deepStrictEqual(log.getAndClearEntries(), [ + assert.deepStrictEqual(log.getAndClearEntries(), ([ ">>> before forceUpdate", ">>> before get", "handle change: editor.versionId undefined", "running derived: selection: [1,2 -> 1,2], value: 2", "<<< after get", - 'handle change: editor.onDidType "a"', - 'handle change: editor.versionId {"changes":[{"range":"[1,1 -> 1,1]","rangeLength":0,"text":"a","rangeOffset":0}],"eol":"\\n","versionId":2}', - 'handle change: editor.selections {"selection":"[1,2 -> 1,2]","modelVersionId":2,"oldSelections":["[1,1 -> 1,1]"],"oldModelVersionId":1,"source":"keyboard","reason":0}', - "running derived: selection: [1,2 -> 1,2], value: 2", - ]); + "handle change: editor.onDidType \"a\"", + "handle change: editor.versionId {\"changes\":[{\"range\":\"[1,1 -> 1,1]\",\"rangeLength\":0,\"text\":\"a\",\"rangeOffset\":0}],\"eol\":\"\\n\",\"versionId\":2,\"detailedReasons\":[{\"metadata\":{\"source\":\"cursor\",\"kind\":\"type\",\"detailedSource\":\"keyboard\"}}],\"detailedReasonsChangeLengths\":[1]}", + "handle change: editor.selections {\"selection\":\"[1,2 -> 1,2]\",\"modelVersionId\":2,\"oldSelections\":[\"[1,1 -> 1,1]\"],\"oldModelVersionId\":1,\"source\":\"keyboard\",\"reason\":0}", + "running derived: selection: [1,2 -> 1,2], value: 2" + ])); } ); }); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 7e7436a5b00..12b758c304b 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -2329,9 +2329,9 @@ declare namespace monaco.editor { * @param operations The edit operations. * @return If desired, the inverse edit operations, that, when applied, will bring the model back to the previous state. */ - applyEdits(operations: IIdentifiedSingleEditOperation[]): void; - applyEdits(operations: IIdentifiedSingleEditOperation[], computeUndoEdits: false): void; - applyEdits(operations: IIdentifiedSingleEditOperation[], computeUndoEdits: true): IValidEditOperation[]; + applyEdits(operations: readonly IIdentifiedSingleEditOperation[]): void; + applyEdits(operations: readonly IIdentifiedSingleEditOperation[], computeUndoEdits: false): void; + applyEdits(operations: readonly IIdentifiedSingleEditOperation[], computeUndoEdits: true): IValidEditOperation[]; /** * Change the end of line sequence without recording in the undo stack. * This can have dire consequences on the undo stack! See @pushEOL for the preferred way. @@ -2963,6 +2963,43 @@ declare namespace monaco.editor { * Flag that indicates that this event describes an eol change. */ readonly isEolChange: boolean; + /** + * The sum of these lengths equals changes.length. + * The length of this array must equal the length of detailedReasons. + */ + readonly detailedReasonsChangeLengths: number[]; + } + + export interface ISerializedModelContentChangedEvent { + /** + * The changes are ordered from the end of the document to the beginning, so they should be safe to apply in sequence. + */ + readonly changes: IModelContentChange[]; + /** + * The (new) end-of-line character. + */ + readonly eol: string; + /** + * The new version id the model has transitioned to. + */ + readonly versionId: number; + /** + * Flag that indicates that this event was generated while undoing. + */ + readonly isUndoing: boolean; + /** + * Flag that indicates that this event was generated while redoing. + */ + readonly isRedoing: boolean; + /** + * Flag that indicates that all decorations were lost with this edit. + * The model has been reset to a new value. + */ + readonly isFlush: boolean; + /** + * Flag that indicates that this event describes an eol change. + */ + readonly isEolChange: boolean; } /** diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index c67f92994a9..7436d76f268 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -380,6 +380,9 @@ const _allApiProposals = { testRelatedCode: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testRelatedCode.d.ts', }, + textDocumentChangeReason: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.textDocumentChangeReason.d.ts', + }, textEditorDiffInformation: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts', }, diff --git a/src/vs/workbench/api/browser/mainThreadDocuments.ts b/src/vs/workbench/api/browser/mainThreadDocuments.ts index 91655eb0ff3..772493301e6 100644 --- a/src/vs/workbench/api/browser/mainThreadDocuments.ts +++ b/src/vs/workbench/api/browser/mainThreadDocuments.ts @@ -22,7 +22,8 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { IPathService } from '../../services/path/common/pathService.js'; import { ResourceMap } from '../../../base/common/map.js'; import { IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ErrorNoTelemetry } from '../../../base/common/errors.js'; +import { ErrorNoTelemetry, onUnexpectedError } from '../../../base/common/errors.js'; +import { ISerializedModelContentChangedEvent } from '../../../editor/common/textModelEvents.js'; export class BoundModelReferenceCollection { @@ -96,7 +97,21 @@ class ModelTracker extends Disposable { this._knownVersionId = this._model.getVersionId(); this._store.add(this._model.onDidChangeContent((e) => { this._knownVersionId = e.versionId; - this._proxy.$acceptModelChanged(this._model.uri, e, this._textFileService.isDirty(this._model.uri)); + if (e.detailedReasonsChangeLengths.length !== 1) { + onUnexpectedError(new Error(`Unexpected reasons: ${e.detailedReasons.map(r => r.toString())}`)); + } + + const evt: ISerializedModelContentChangedEvent = { + changes: e.changes, + isEolChange: e.isEolChange, + isUndoing: e.isUndoing, + isRedoing: e.isRedoing, + isFlush: e.isFlush, + eol: e.eol, + versionId: e.versionId, + detailedReason: e.detailedReasons[0].metadata, + }; + this._proxy.$acceptModelChanged(this._model.uri, evt, this._textFileService.isDirty(this._model.uri)); if (this.isCaughtUpWithContentChanges()) { this._onIsCaughtUpWithContentChanges.fire(this._model.uri); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 24bb586976c..5e1572b7576 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1071,6 +1071,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return _asExtensionEvent(extHostDocuments.onDidRemoveDocument)(listener, thisArgs, disposables); }, onDidChangeTextDocument: (listener, thisArgs?, disposables?) => { + if (isProposedApiEnabled(extension, 'textDocumentChangeReason')) { + return _asExtensionEvent(extHostDocuments.onDidChangeDocumentWithReason)(listener, thisArgs, disposables); + } return _asExtensionEvent(extHostDocuments.onDidChangeDocument)(listener, thisArgs, disposables); }, onDidSaveTextDocument: (listener, thisArgs?, disposables?) => { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 2470e9c52b1..870a8d3e51c 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -28,7 +28,7 @@ import * as languages from '../../../editor/common/languages.js'; import { CompletionItemLabel } from '../../../editor/common/languages.js'; import { CharacterPair, CommentRule, EnterAction } from '../../../editor/common/languages/languageConfiguration.js'; import { EndOfLineSequence } from '../../../editor/common/model.js'; -import { IModelChangedEvent } from '../../../editor/common/model/mirrorTextModel.js'; +import { ISerializedModelContentChangedEvent } from '../../../editor/common/textModelEvents.js'; import { IAccessibilityInformation } from '../../../platform/accessibility/common/accessibility.js'; import { ILocalizedString } from '../../../platform/action/common/action.js'; import { ConfigurationTarget, IConfigurationChange, IConfigurationData, IConfigurationOverrides } from '../../../platform/configuration/common/configuration.js'; @@ -1840,7 +1840,7 @@ export interface ExtHostDocumentsShape { $acceptModelSaved(strURL: UriComponents): void; $acceptDirtyStateChanged(strURL: UriComponents, isDirty: boolean): void; $acceptEncodingChanged(strURL: UriComponents, encoding: string): void; - $acceptModelChanged(strURL: UriComponents, e: IModelChangedEvent, isDirty: boolean): void; + $acceptModelChanged(strURL: UriComponents, e: ISerializedModelContentChangedEvent, isDirty: boolean): void; } export interface ExtHostDocumentSaveParticipantShape { diff --git a/src/vs/workbench/api/common/extHostDocuments.ts b/src/vs/workbench/api/common/extHostDocuments.ts index bbf8b4200c7..f798da88a91 100644 --- a/src/vs/workbench/api/common/extHostDocuments.ts +++ b/src/vs/workbench/api/common/extHostDocuments.ts @@ -6,7 +6,6 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; -import { IModelChangedEvent } from '../../../editor/common/model/mirrorTextModel.js'; import { ExtHostDocumentsShape, IMainContext, MainContext, MainThreadDocumentsShape } from './extHost.protocol.js'; import { ExtHostDocumentData, setWordDefinitionFor } from './extHostDocumentData.js'; import { ExtHostDocumentsAndEditors } from './extHostDocumentsAndEditors.js'; @@ -15,17 +14,20 @@ import type * as vscode from 'vscode'; import { assertReturnsDefined } from '../../../base/common/types.js'; import { deepFreeze } from '../../../base/common/objects.js'; import { TextDocumentChangeReason } from './extHostTypes.js'; +import { ISerializedModelContentChangedEvent } from '../../../editor/common/textModelEvents.js'; export class ExtHostDocuments implements ExtHostDocumentsShape { private readonly _onDidAddDocument = new Emitter(); private readonly _onDidRemoveDocument = new Emitter(); - private readonly _onDidChangeDocument = new Emitter(); + private readonly _onDidChangeDocument = new Emitter>(); + private readonly _onDidChangeDocumentWithReason = new Emitter(); private readonly _onDidSaveDocument = new Emitter(); readonly onDidAddDocument: Event = this._onDidAddDocument.event; readonly onDidRemoveDocument: Event = this._onDidRemoveDocument.event; - readonly onDidChangeDocument: Event = this._onDidChangeDocument.event; + readonly onDidChangeDocument: Event = this._onDidChangeDocument.event as Event; + readonly onDidChangeDocumentWithReason: Event = this._onDidChangeDocumentWithReason.event; readonly onDidSaveDocument: Event = this._onDidSaveDocument.event; private readonly _toDispose = new DisposableStore(); @@ -145,7 +147,13 @@ export class ExtHostDocuments implements ExtHostDocumentsShape { this._onDidChangeDocument.fire({ document: data.document, contentChanges: [], - reason: undefined + reason: undefined, + }); + this._onDidChangeDocumentWithReason.fire({ + document: data.document, + contentChanges: [], + reason: undefined, + detailedReason: undefined, }); } @@ -159,11 +167,17 @@ export class ExtHostDocuments implements ExtHostDocumentsShape { this._onDidChangeDocument.fire({ document: data.document, contentChanges: [], - reason: undefined + reason: undefined, + }); + this._onDidChangeDocumentWithReason.fire({ + document: data.document, + contentChanges: [], + reason: undefined, + detailedReason: undefined, }); } - public $acceptModelChanged(uriComponents: UriComponents, events: IModelChangedEvent, isDirty: boolean): void { + public $acceptModelChanged(uriComponents: UriComponents, events: ISerializedModelContentChangedEvent, isDirty: boolean): void { const uri = URI.revive(uriComponents); const data = this._documentsAndEditors.getDocument(uri); if (!data) { @@ -179,7 +193,7 @@ export class ExtHostDocuments implements ExtHostDocumentsShape { reason = TextDocumentChangeReason.Redo; } - this._onDidChangeDocument.fire(deepFreeze({ + this._onDidChangeDocument.fire(deepFreeze>({ document: data.document, contentChanges: events.changes.map((change) => { return { @@ -189,7 +203,23 @@ export class ExtHostDocuments implements ExtHostDocumentsShape { text: change.text }; }), - reason + reason, + })); + this._onDidChangeDocumentWithReason.fire(deepFreeze({ + document: data.document, + contentChanges: events.changes.map((change) => { + return { + range: TypeConverters.Range.to(change.range), + rangeOffset: change.rangeOffset, + rangeLength: change.rangeLength, + text: change.text + }; + }), + reason, + detailedReason: events.detailedReason ? { + source: events.detailedReason.source, + metadata: events.detailedReason, + } : undefined, })); } diff --git a/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts b/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts index f2ccb19be70..d3d02ba3ff4 100644 --- a/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts +++ b/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts @@ -303,6 +303,9 @@ suite('ExtHostDocumentSaveParticipant', () => { versionId: 2, isRedoing: false, isUndoing: false, + detailedReason: undefined, + isFlush: false, + isEolChange: false, }, true); e.waitUntil(Promise.resolve([TextEdit.insert(new Position(0, 0), 'bar')])); @@ -337,6 +340,9 @@ suite('ExtHostDocumentSaveParticipant', () => { versionId: documents.getDocumentData(uri)!.version + 1, isRedoing: false, isUndoing: false, + detailedReason: undefined, + isFlush: false, + isEolChange: false, }, true); // } } diff --git a/src/vs/workbench/common/editor/textEditorModel.ts b/src/vs/workbench/common/editor/textEditorModel.ts index 53b900ec6ed..c9d5e606e3a 100644 --- a/src/vs/workbench/common/editor/textEditorModel.ts +++ b/src/vs/workbench/common/editor/textEditorModel.ts @@ -17,6 +17,7 @@ import { ThrottledDelayer } from '../../../base/common/async.js'; import { IAccessibilityService } from '../../../platform/accessibility/common/accessibility.js'; import { localize } from '../../../nls.js'; import { IMarkdownString } from '../../../base/common/htmlContent.js'; +import { TextModelEditReason } from '../../../editor/common/textModelEditReason.js'; /** * The base text editor model leverages the code editor model. This class is only intended to be subclassed and not instantiated. @@ -214,14 +215,14 @@ export class BaseTextEditorModel extends EditorModel implements ITextEditorModel /** * Updates the text editor model with the provided value. If the value is the same as the model has, this is a no-op. */ - updateTextEditorModel(newValue?: ITextBufferFactory, preferredLanguageId?: string): void { + updateTextEditorModel(newValue?: ITextBufferFactory, preferredLanguageId?: string, reason?: TextModelEditReason): void { if (!this.isResolved()) { return; } // contents if (newValue) { - this.modelService.updateModel(this.textEditorModel, newValue); + this.modelService.updateModel(this.textEditorModel, newValue, reason); } // language (only if specific and changed) diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.ts index d4d42d87356..2987aa13e7a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTextModelChangeService.ts @@ -212,12 +212,12 @@ export class ChatEditingTextModelChangeService extends Disposable { this._isEditFromUs = true; // make the actual edit let result: ISingleEditOperation[] = []; - TextModelEditReason.editWithReason(new TextModelEditReason({ source: 'Chat.applyEdits' }), () => { - this.modifiedModel.pushEditOperations(null, edits, (undoEdits) => { - result = undoEdits; - return null; - }); - }); + + this.modifiedModel.pushEditOperations(null, edits, (undoEdits) => { + result = undoEdits; + return null; + }, undefined, new TextModelEditReason({ source: 'Chat.applyEdits' })); + return result; } finally { this._isEditFromUs = false; diff --git a/src/vs/workbench/contrib/inlineChat/browser/utils.ts b/src/vs/workbench/contrib/inlineChat/browser/utils.ts index c20e7ac1127..3c1d0338289 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/utils.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/utils.ts @@ -48,12 +48,12 @@ export async function performAsyncTextEdit(model: ITextModel, edit: AsyncTextEdi ? EditOperation.replace(range, part) // first edit needs to override the "anchor" : EditOperation.insert(range.getEndPosition(), part); obs?.start(); - TextModelEditReason.editWithReason(new TextModelEditReason({ source: 'inlineChat.applyEdit' }), () => { - model.pushEditOperations(null, [edit], (undoEdits) => { - progress?.report(undoEdits); - return null; - }); - }); + + model.pushEditOperations(null, [edit], (undoEdits) => { + progress?.report(undoEdits); + return null; + }, undefined, new TextModelEditReason({ source: 'inlineChat.applyEdit' })); + obs?.stop(); first = false; } diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts index 6ab27644450..488f8618de5 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts @@ -536,9 +536,7 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil // Update Existing Model if (this.textEditorModel) { - this.textEditorModel.editWithReason(new TextModelEditReason({ source: 'reloadFromDisk' }), () => { - this.doUpdateTextModel(content.value); - }); + this.doUpdateTextModel(content.value, new TextModelEditReason({ source: 'reloadFromDisk' })); } // Create New Model @@ -570,13 +568,13 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil this.autoDetectLanguage(); } - private doUpdateTextModel(value: ITextBufferFactory): void { + private doUpdateTextModel(value: ITextBufferFactory, reason: TextModelEditReason): void { this.trace('doUpdateTextModel()'); // Update model value in a block that ignores content change events for dirty tracking this.ignoreDirtyOnModelContentChange = true; try { - this.updateTextEditorModel(value, this.preferredLanguageId); + this.updateTextEditorModel(value, this.preferredLanguageId, reason); } finally { this.ignoreDirtyOnModelContentChange = false; } diff --git a/src/vscode-dts/vscode.proposed.textDocumentChangeReason.d.ts b/src/vscode-dts/vscode.proposed.textDocumentChangeReason.d.ts new file mode 100644 index 00000000000..f656c304da2 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.textDocumentChangeReason.d.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + /** + * Detailed information about why a text document changed. + */ + export interface TextDocumentDetailedChangeReason { + /** + * The source of the change (e.g., 'inline-completion', 'chat-edit', 'extension') + */ + readonly source: string; + + /** + * Additional context-specific metadata + */ + readonly metadata: { readonly [key: string]: any }; + } + + export interface TextDocumentChangeEvent { + /** + * The precise reason for the document change. + * Only available to extensions that have enabled the `textDocumentChangeReason` proposed API. + */ + readonly detailedReason: TextDocumentDetailedChangeReason | undefined; + } +}