Implements edit source tracking through proposed API (#252430)

Implements edit source tracking through proposed API
This commit is contained in:
Henning Dieterichs 2025-06-26 10:52:51 +02:00 committed by GitHub
parent 189d83c590
commit 488d6df795
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 417 additions and 208 deletions

View File

@ -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.

View File

@ -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<IIdentifiedSingleEditOperation>(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;
}

View File

@ -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,

View File

@ -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;
}

View File

@ -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<T>(editReason: TextModelEditReason, cb: () => T): T;
}
/**

View File

@ -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) => {

View File

@ -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<T>(editReason: TextModelEditReason, cb: () => T): T {
return TextModelEditReason.editWithReason(editReason, cb);
}
}
export function indentOfLine(line: string): number {

View File

@ -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<IModelService>('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;

View File

@ -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();

View File

@ -4,38 +4,29 @@
*--------------------------------------------------------------------------------------------*/
export class TextModelEditReason {
private static _nextMetadataId = 0;
private static _metaDataMap = new Map<number, ITextModelEditReasonMetadata>();
/**
* Sets the reason for all text model edits done in the callback.
*/
public static editWithReason<T>(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;
};

View File

@ -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),
};
}
}

View File

@ -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));

View File

@ -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);
}));
}));
}

View File

@ -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 {

View File

@ -25,7 +25,6 @@ export type LogEntryData = IEventFetchEnd;
export interface IDocumentEventDataSetChangeReason {
sourceId: 'TextModel.setChangeReason';
source: 'inlineSuggestion.accept' | 'snippet' | string;
detailedSource?: string;
}
interface IDocumentEventFetchStart {

View File

@ -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), '<div id=""');
viewModel.type('a', 'keyboard');

View File

@ -41,6 +41,7 @@ suite("CodeEditorWidget", () => {
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"
]));
}
);
});

43
src/vs/monaco.d.ts vendored
View File

@ -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;
}
/**

View File

@ -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',
},

View File

@ -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);
}

View File

@ -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?) => {

View File

@ -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 {

View File

@ -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<vscode.TextDocument>();
private readonly _onDidRemoveDocument = new Emitter<vscode.TextDocument>();
private readonly _onDidChangeDocument = new Emitter<vscode.TextDocumentChangeEvent>();
private readonly _onDidChangeDocument = new Emitter<Omit<vscode.TextDocumentChangeEvent, 'detailedReason'>>();
private readonly _onDidChangeDocumentWithReason = new Emitter<vscode.TextDocumentChangeEvent>();
private readonly _onDidSaveDocument = new Emitter<vscode.TextDocument>();
readonly onDidAddDocument: Event<vscode.TextDocument> = this._onDidAddDocument.event;
readonly onDidRemoveDocument: Event<vscode.TextDocument> = this._onDidRemoveDocument.event;
readonly onDidChangeDocument: Event<vscode.TextDocumentChangeEvent> = this._onDidChangeDocument.event;
readonly onDidChangeDocument: Event<vscode.TextDocumentChangeEvent> = this._onDidChangeDocument.event as Event<vscode.TextDocumentChangeEvent>;
readonly onDidChangeDocumentWithReason: Event<vscode.TextDocumentChangeEvent> = this._onDidChangeDocumentWithReason.event;
readonly onDidSaveDocument: Event<vscode.TextDocument> = 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<Omit<vscode.TextDocumentChangeEvent, 'detailedReason'>>({
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<vscode.TextDocumentChangeEvent>({
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,
}));
}

View File

@ -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);
// }
}

View File

@ -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)

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}
}