Moves ARC telemetry tracking to core (#253919)

This commit is contained in:
Henning Dieterichs 2025-07-03 17:56:30 +02:00 committed by GitHub
parent cc1eecb9f0
commit c6f916e443
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1922 additions and 85 deletions

View File

@ -27,7 +27,7 @@ export { observableFromEventOpts } from './observables/observableFromEvent.js';
export { observableSignalFromEvent } from './observables/observableSignalFromEvent.js';
export { asyncTransaction, globalTransaction, subtransaction, transaction, TransactionImpl } from './transaction.js';
export { observableFromValueWithChangeEvent, ValueWithChangeEventFromObservable } from './utils/valueWithChangeEvent.js';
export { runOnChange, runOnChangeWithCancellationToken, runOnChangeWithStore } from './utils/runOnChange.js';
export { runOnChange, runOnChangeWithCancellationToken, runOnChangeWithStore, type RemoveUndefined } from './utils/runOnChange.js';
export { derivedConstOnceDefined, latestChangedValue } from './experimental/utils.js';
export { observableFromEvent } from './observables/observableFromEvent.js';
export { observableValue } from './observables/observableValue.js';

View File

@ -33,6 +33,8 @@ import { mainWindow } from '../../../base/browser/window.js';
import { WindowIntervalTimer } from '../../../base/browser/dom.js';
import { WorkerTextModelSyncClient } from '../../common/services/textModelSync/textModelSync.impl.js';
import { EditorWorkerHost } from '../../common/services/editorWorkerHost.js';
import { StringEdit } from '../../common/core/edits/stringEdit.js';
import { OffsetRange } from '../../common/core/ranges/offsetRange.js';
/**
* Stop the worker if it was not needed for 5 min.
@ -180,6 +182,17 @@ export abstract class EditorWorkerService extends Disposable implements IEditorW
}
}
public async computeStringEditFromDiff(original: string, modified: string, options: { maxComputationTimeMs: number }, algorithm: DiffAlgorithmName): Promise<StringEdit> {
try {
const worker = await this._workerWithResources([]);
const edit = await worker.$computeStringDiff(original, modified, options, algorithm);
return StringEdit.fromJson(edit);
} catch (e) {
onUnexpectedError(e);
return StringEdit.replace(OffsetRange.ofLength(original.length), modified); // approximation
}
}
public canNavigateValueSet(resource: URI): boolean {
return (canSyncModel(this._modelService, resource));
}

View File

@ -48,7 +48,7 @@ export abstract class BaseEdit<T extends BaseReplacement<T>, TEdit extends BaseE
* Normalizes the edit by removing empty replacements and joining touching replacements (if the replacements allow joining).
* Two edits have an equal normalized edit if and only if they have the same effect on any input.
*
* ![](./docs/BaseEdit_normalize.dio.png)
* ![](https://raw.githubusercontent.com/microsoft/vscode/refs/heads/main/src/vs/editor/common/core/edits/docs/BaseEdit_normalize.drawio.png)
*
* Invariant:
* ```
@ -90,7 +90,7 @@ export abstract class BaseEdit<T extends BaseReplacement<T>, TEdit extends BaseE
/**
* Combines two edits into one with the same effect.
*
* ![](./docs/BaseEdit_compose.dio.png)
* ![](https://raw.githubusercontent.com/microsoft/vscode/refs/heads/main/src/vs/editor/common/core/edits/docs/BaseEdit_compose.drawio.png)
*
* Invariant:
* ```
@ -183,6 +183,22 @@ export abstract class BaseEdit<T extends BaseReplacement<T>, TEdit extends BaseE
return this._createNew(result).normalize();
}
public decomposeSplit(shouldBeInE1: (repl: T) => boolean): { e1: TEdit; e2: TEdit } {
const e1: T[] = [];
const e2: T[] = [];
let e2delta = 0;
for (const edit of this.replacements) {
if (shouldBeInE1(edit)) {
e1.push(edit);
e2delta += edit.getNewLength() - edit.replaceRange.length;
} else {
e2.push(edit.slice(edit.replaceRange.delta(e2delta), new OffsetRange(0, edit.getNewLength())));
}
}
return { e1: this._createNew(e1), e2: this._createNew(e2) };
}
/**
* Returns the range of each replacement in the applied value.
*/

View File

@ -5,58 +5,26 @@
import { commonPrefixLength, commonSuffixLength } from '../../../../base/common/strings.js';
import { OffsetRange } from '../ranges/offsetRange.js';
import { StringText } from '../text/abstractText.js';
import { BaseEdit, BaseReplacement } from './edit.js';
/**
* Represents a set of replacements to a string.
* All these replacements are applied at once.
*/
export class StringEdit extends BaseEdit<StringReplacement, StringEdit> {
public static readonly empty = new StringEdit([]);
public static create(replacements: readonly StringReplacement[]): StringEdit {
return new StringEdit(replacements);
export abstract class BaseStringEdit<T extends BaseStringReplacement<T> = BaseStringReplacement<any>, TEdit extends BaseStringEdit<T, TEdit> = BaseStringEdit<any, any>> extends BaseEdit<T, TEdit> {
get TReplacement(): T {
throw new Error('TReplacement is not defined for BaseStringEdit');
}
public static single(replacement: StringReplacement): StringEdit {
return new StringEdit([replacement]);
}
public static replace(range: OffsetRange, replacement: string): StringEdit {
return new StringEdit([new StringReplacement(range, replacement)]);
}
public static insert(offset: number, replacement: string): StringEdit {
return new StringEdit([new StringReplacement(OffsetRange.emptyAt(offset), replacement)]);
}
public static delete(range: OffsetRange): StringEdit {
return new StringEdit([new StringReplacement(range, '')]);
}
public static fromJson(data: ISerializedStringEdit): StringEdit {
return new StringEdit(data.map(StringReplacement.fromJson));
}
public static compose(edits: readonly StringEdit[]): StringEdit {
public static composeOrUndefined<T extends BaseStringEdit>(edits: readonly T[]): T | undefined {
if (edits.length === 0) {
return StringEdit.empty;
return undefined;
}
let result = edits[0];
for (let i = 1; i < edits.length; i++) {
result = result.compose(edits[i]);
result = result.compose(edits[i]) as any;
}
return result;
}
constructor(replacements: readonly StringReplacement[]) {
super(replacements);
}
protected override _createNew(replacements: readonly StringReplacement[]): StringEdit {
return new StringEdit(replacements);
}
public apply(base: string): string {
const resultText: string[] = [];
let pos = 0;
@ -162,39 +130,41 @@ export class StringEdit extends BaseEdit<StringReplacement, StringEdit> {
public normalizeEOL(eol: '\r\n' | '\n'): StringEdit {
return new StringEdit(this.replacements.map(edit => edit.normalizeEOL(eol)));
}
/**
* If `e1.apply(source) === e2.apply(source)`, then `e1.normalizeOnSource(source).equals(e2.normalizeOnSource(source))`.
*/
public normalizeOnSource(source: string): StringEdit {
const result = this.apply(source);
const edit = StringReplacement.replace(OffsetRange.ofLength(source.length), result);
const e = edit.removeCommonSuffixAndPrefix(source);
if (e.isEmpty) {
return StringEdit.empty;
}
return e.toEdit();
}
removeCommonSuffixAndPrefix(source: string): TEdit {
return this._createNew(this.replacements.map(e => e.removeCommonSuffixAndPrefix(source))).normalize();
}
applyOnText(docContents: StringText): StringText {
return new StringText(this.apply(docContents.value));
}
public mapData<TData extends IEditData<TData>>(f: (replacement: T) => TData): AnnotatedStringEdit<TData> {
return new AnnotatedStringEdit(
this.replacements.map(e => new AnnotatedStringReplacement(
e.replaceRange,
e.newText,
f(e)
))
);
}
}
/**
* Warning: Be careful when changing this type, as it is used for serialization!
*/
export type ISerializedStringEdit = ISerializedStringReplacement[];
/**
* Warning: Be careful when changing this type, as it is used for serialization!
*/
export interface ISerializedStringReplacement {
txt: string;
pos: number;
len: number;
}
export class StringReplacement extends BaseReplacement<StringReplacement> {
public static insert(offset: number, text: string): StringReplacement {
return new StringReplacement(OffsetRange.emptyAt(offset), text);
}
public static replace(range: OffsetRange, text: string): StringReplacement {
return new StringReplacement(range, text);
}
public static delete(range: OffsetRange): StringReplacement {
return new StringReplacement(range, '');
}
public static fromJson(data: ISerializedStringReplacement): StringReplacement {
return new StringReplacement(OffsetRange.ofStartAndLength(data.pos, data.len), data.txt);
}
export abstract class BaseStringReplacement<T extends BaseStringReplacement<T> = BaseStringReplacement<any>> extends BaseReplacement<T> {
constructor(
range: OffsetRange,
public readonly newText: string,
@ -202,20 +172,8 @@ export class StringReplacement extends BaseReplacement<StringReplacement> {
super(range);
}
override equals(other: StringReplacement): boolean {
return this.replaceRange.equals(other.replaceRange) && this.newText === other.newText;
}
getNewLength(): number { return this.newText.length; }
tryJoinTouching(other: StringReplacement): StringReplacement | undefined {
return new StringReplacement(this.replaceRange.joinRightTouching(other.replaceRange), this.newText + other.newText);
}
slice(range: OffsetRange, rangeInReplacement: OffsetRange): StringReplacement {
return new StringReplacement(range, rangeInReplacement.substring(this.newText));
}
override toString(): string {
return `${this.replaceRange} -> "${this.newText}"`;
}
@ -254,6 +212,155 @@ export class StringReplacement extends BaseReplacement<StringReplacement> {
const newText = this.newText.replace(/\r\n|\n/g, eol);
return new StringReplacement(this.replaceRange, newText);
}
public removeCommonSuffixAndPrefix(source: string): T {
return this.removeCommonSuffix(source).removeCommonPrefix(source);
}
public removeCommonPrefix(source: string): T {
const oldText = this.replaceRange.substring(source);
const prefixLen = commonPrefixLength(oldText, this.newText);
if (prefixLen === 0) {
return this as unknown as T;
}
return this.slice(this.replaceRange.deltaStart(prefixLen), new OffsetRange(prefixLen, this.newText.length));
}
public removeCommonSuffix(source: string): T {
const oldText = this.replaceRange.substring(source);
const suffixLen = commonSuffixLength(oldText, this.newText);
if (suffixLen === 0) {
return this as unknown as T;
}
return this.slice(this.replaceRange.deltaEnd(-suffixLen), new OffsetRange(0, this.newText.length - suffixLen));
}
public toEdit(): StringEdit {
return new StringEdit([this]);
}
}
/**
* Represents a set of replacements to a string.
* All these replacements are applied at once.
*/
export class StringEdit extends BaseStringEdit<StringReplacement, StringEdit> {
public static readonly empty = new StringEdit([]);
public static create(replacements: readonly StringReplacement[]): StringEdit {
return new StringEdit(replacements);
}
public static single(replacement: StringReplacement): StringEdit {
return new StringEdit([replacement]);
}
public static replace(range: OffsetRange, replacement: string): StringEdit {
return new StringEdit([new StringReplacement(range, replacement)]);
}
public static insert(offset: number, replacement: string): StringEdit {
return new StringEdit([new StringReplacement(OffsetRange.emptyAt(offset), replacement)]);
}
public static delete(range: OffsetRange): StringEdit {
return new StringEdit([new StringReplacement(range, '')]);
}
public static fromJson(data: ISerializedStringEdit): StringEdit {
return new StringEdit(data.map(StringReplacement.fromJson));
}
public static compose(edits: readonly StringEdit[]): StringEdit {
if (edits.length === 0) {
return StringEdit.empty;
}
let result = edits[0];
for (let i = 1; i < edits.length; i++) {
result = result.compose(edits[i]);
}
return result;
}
/**
* The replacements are applied in order!
* Equals `StringEdit.compose(replacements.map(r => r.toEdit()))`, but is much more performant.
*/
public static composeSequentialReplacements(replacements: readonly StringReplacement[]): StringEdit {
let edit = StringEdit.empty;
let curEditReplacements: StringReplacement[] = []; // These are reverse sorted
for (const r of replacements) {
const last = curEditReplacements.at(-1);
if (!last || r.replaceRange.isBefore(last.replaceRange)) {
// Detect subsequences of reverse sorted replacements
curEditReplacements.push(r);
} else {
// Once the subsequence is broken, compose the current replacements and look for a new subsequence.
edit = edit.compose(StringEdit.create(curEditReplacements.reverse()));
curEditReplacements = [r];
}
}
edit = edit.compose(StringEdit.create(curEditReplacements.reverse()));
return edit;
}
constructor(replacements: readonly StringReplacement[]) {
super(replacements);
}
protected override _createNew(replacements: readonly StringReplacement[]): StringEdit {
return new StringEdit(replacements);
}
}
/**
* Warning: Be careful when changing this type, as it is used for serialization!
*/
export type ISerializedStringEdit = ISerializedStringReplacement[];
/**
* Warning: Be careful when changing this type, as it is used for serialization!
*/
export interface ISerializedStringReplacement {
txt: string;
pos: number;
len: number;
}
export class StringReplacement extends BaseStringReplacement<StringReplacement> {
public static insert(offset: number, text: string): StringReplacement {
return new StringReplacement(OffsetRange.emptyAt(offset), text);
}
public static replace(range: OffsetRange, text: string): StringReplacement {
return new StringReplacement(range, text);
}
public static delete(range: OffsetRange): StringReplacement {
return new StringReplacement(range, '');
}
public static fromJson(data: ISerializedStringReplacement): StringReplacement {
return new StringReplacement(OffsetRange.ofStartAndLength(data.pos, data.len), data.txt);
}
override equals(other: StringReplacement): boolean {
return this.replaceRange.equals(other.replaceRange) && this.newText === other.newText;
}
override tryJoinTouching(other: StringReplacement): StringReplacement | undefined {
return new StringReplacement(this.replaceRange.joinRightTouching(other.replaceRange), this.newText + other.newText);
}
override slice(range: OffsetRange, rangeInReplacement: OffsetRange): StringReplacement {
return new StringReplacement(range, rangeInReplacement.substring(this.newText));
}
}
export function applyEditsToRanges(sortedRanges: OffsetRange[], edit: StringEdit): OffsetRange[] {
@ -322,3 +429,106 @@ export function applyEditsToRanges(sortedRanges: OffsetRange[], edit: StringEdit
return result;
}
/**
* Represents data associated to a single edit, which survives certain edit operations.
*/
export interface IEditData<T> {
join(other: T): T | undefined;
}
export class VoidEditData implements IEditData<VoidEditData> {
join(other: VoidEditData): VoidEditData | undefined {
return this;
}
}
/**
* Represents a set of replacements to a string.
* All these replacements are applied at once.
*/
export class AnnotatedStringEdit<T extends IEditData<T>> extends BaseStringEdit<AnnotatedStringReplacement<T>, AnnotatedStringEdit<T>> {
public static readonly empty = new AnnotatedStringEdit<never>([]);
public static create<T extends IEditData<T>>(replacements: readonly AnnotatedStringReplacement<T>[]): AnnotatedStringEdit<T> {
return new AnnotatedStringEdit(replacements);
}
public static single<T extends IEditData<T>>(replacement: AnnotatedStringReplacement<T>): AnnotatedStringEdit<T> {
return new AnnotatedStringEdit([replacement]);
}
public static replace<T extends IEditData<T>>(range: OffsetRange, replacement: string, data: T): AnnotatedStringEdit<T> {
return new AnnotatedStringEdit([new AnnotatedStringReplacement(range, replacement, data)]);
}
public static insert<T extends IEditData<T>>(offset: number, replacement: string, data: T): AnnotatedStringEdit<T> {
return new AnnotatedStringEdit([new AnnotatedStringReplacement(OffsetRange.emptyAt(offset), replacement, data)]);
}
public static delete<T extends IEditData<T>>(range: OffsetRange, data: T): AnnotatedStringEdit<T> {
return new AnnotatedStringEdit([new AnnotatedStringReplacement(range, '', data)]);
}
public static compose<T extends IEditData<T>>(edits: readonly AnnotatedStringEdit<T>[]): AnnotatedStringEdit<T> {
if (edits.length === 0) {
return AnnotatedStringEdit.empty;
}
let result = edits[0];
for (let i = 1; i < edits.length; i++) {
result = result.compose(edits[i]);
}
return result;
}
constructor(replacements: readonly AnnotatedStringReplacement<T>[]) {
super(replacements);
}
protected override _createNew(replacements: readonly AnnotatedStringReplacement<T>[]): AnnotatedStringEdit<T> {
return new AnnotatedStringEdit<T>(replacements);
}
toStringEdit(): StringEdit {
return new StringEdit(this.replacements.map(e => new StringReplacement(e.replaceRange, e.newText)));
}
}
export class AnnotatedStringReplacement<T extends IEditData<T>> extends BaseStringReplacement<AnnotatedStringReplacement<T>> {
public static insert<T extends IEditData<T>>(offset: number, text: string, data: T): AnnotatedStringReplacement<T> {
return new AnnotatedStringReplacement<T>(OffsetRange.emptyAt(offset), text, data);
}
public static replace<T extends IEditData<T>>(range: OffsetRange, text: string, data: T): AnnotatedStringReplacement<T> {
return new AnnotatedStringReplacement<T>(range, text, data);
}
public static delete<T extends IEditData<T>>(range: OffsetRange, data: T): AnnotatedStringReplacement<T> {
return new AnnotatedStringReplacement<T>(range, '', data);
}
constructor(
range: OffsetRange,
newText: string,
public readonly data: T
) {
super(range, newText);
}
override equals(other: AnnotatedStringReplacement<T>): boolean {
return this.replaceRange.equals(other.replaceRange) && this.newText === other.newText && this.data === other.data;
}
tryJoinTouching(other: AnnotatedStringReplacement<T>): AnnotatedStringReplacement<T> | undefined {
const joined = this.data.join(other.data);
if (joined === undefined) {
return undefined;
}
return new AnnotatedStringReplacement(this.replaceRange.joinRightTouching(other.replaceRange), this.newText + other.newText, joined);
}
slice(range: OffsetRange, rangeInReplacement?: OffsetRange): AnnotatedStringReplacement<T> {
return new AnnotatedStringReplacement(range, rangeInReplacement ? rangeInReplacement.substring(this.newText) : this.newText, this.data);
}
}

View File

@ -17,3 +17,8 @@ _setPositionOffsetTransformerDependencies({
TextEdit: TextEdit,
TextLength: TextLength,
});
// TODO@hediet this is dept and needs to go. See https://github.com/microsoft/vscode/issues/251126.
export function ensureDependenciesAreSet(): void {
// Noop
}

View File

@ -194,6 +194,17 @@ function isValidLineNumber(lineNumber: number, lines: string[]): boolean {
* Also contains inner range mappings.
*/
export class DetailedLineRangeMapping extends LineRangeMapping {
public static toTextEdit(mapping: readonly DetailedLineRangeMapping[], modified: AbstractText): TextEdit {
const replacements: TextReplacement[] = [];
for (const m of mapping) {
for (const r of m.innerChanges ?? []) {
const replacement = r.toTextEdit(modified);
replacements.push(replacement);
}
}
return new TextEdit(replacements);
}
public static fromRangeMappings(rangeMappings: RangeMapping[]): DetailedLineRangeMapping {
const originalRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.originalRange)));
const modifiedRange = LineRange.join(rangeMappings.map(r => LineRange.fromRangeInclusive(r.modifiedRange)));

View File

@ -28,6 +28,9 @@ import { computeDefaultDocumentColors } from '../languages/defaultDocumentColors
import { FindSectionHeaderOptions, SectionHeader, findSectionHeaders } from './findSectionHeaders.js';
import { IRawModelData, IWorkerTextModelSyncChannelServer } from './textModelSync/textModelSync.protocol.js';
import { ICommonModel, WorkerTextModelSyncServer } from './textModelSync/textModelSync.impl.js';
import { ISerializedStringEdit } from '../core/edits/stringEdit.js';
import { StringText } from '../core/text/abstractText.js';
import { ensureDependenciesAreSet } from '../core/text/positionToOffset.js';
export interface IMirrorModel extends IMirrorTextModel {
readonly uri: URI;
@ -201,6 +204,24 @@ export class EditorWorker implements IDisposable, IWorkerTextModelSyncChannelSer
return diffComputer.computeDiff().changes;
}
public $computeStringDiff(original: string, modified: string, options: { maxComputationTimeMs: number }, algorithm: DiffAlgorithmName): ISerializedStringEdit {
const diffAlgorithm: ILinesDiffComputer = algorithm === 'advanced' ? linesDiffComputers.getDefault() : linesDiffComputers.getLegacy();
ensureDependenciesAreSet();
const originalText = new StringText(original);
const originalLines = originalText.getLines();
const modifiedText = new StringText(modified);
const modifiedLines = modifiedText.getLines();
const result = diffAlgorithm.computeDiff(originalLines, modifiedLines, { ignoreTrimWhitespace: false, maxComputationTimeMs: options.maxComputationTimeMs, computeMoves: false, extendToSubwords: false });
const textEdit = DetailedLineRangeMapping.toTextEdit(result.changes, modifiedText);
const strEdit = originalText.getTransformer().getStringEdit(textEdit);
return strEdit.toJson();
}
// ---- END diff --------------------------------------------------------------------------

View File

@ -12,6 +12,7 @@ import { UnicodeHighlighterOptions } from './unicodeTextModelHighlighter.js';
import { createDecorator } from '../../../platform/instantiation/common/instantiation.js';
import type { EditorWorker } from './editorWebWorker.js';
import { SectionHeader, FindSectionHeaderOptions } from './findSectionHeaders.js';
import { StringEdit } from '../core/edits/stringEdit.js';
export const IEditorWorkerService = createDecorator<IEditorWorkerService>('editorWorkerService');
@ -32,6 +33,8 @@ export interface IEditorWorkerService {
computeMoreMinimalEdits(resource: URI, edits: TextEdit[] | null | undefined, pretty?: boolean): Promise<TextEdit[] | undefined>;
computeHumanReadableDiff(resource: URI, edits: TextEdit[] | null | undefined): Promise<TextEdit[] | undefined>;
computeStringEditFromDiff(original: string, modified: string, options: { maxComputationTimeMs: number }, algorithm: DiffAlgorithmName): Promise<StringEdit>;
canComputeWordRanges(resource: URI): boolean;
computeWordRanges(resource: URI, range: IRange): Promise<{ [word: string]: IRange[] } | null>;

View File

@ -10,6 +10,7 @@ import { TextEdit, IInplaceReplaceSupportResult, IColorInformation } from '../..
import { IDocumentDiff, IDocumentDiffProviderOptions } from '../../../common/diff/documentDiffProvider.js';
import { IChange } from '../../../common/diff/legacyLinesDiffComputer.js';
import { SectionHeader } from '../../../common/services/findSectionHeaders.js';
import { StringEdit } from '../../../common/core/edits/stringEdit.js';
export class TestEditorWorkerService implements IEditorWorkerService {
@ -28,4 +29,8 @@ export class TestEditorWorkerService implements IEditorWorkerService {
async navigateValueSet(resource: URI, range: IRange, up: boolean): Promise<IInplaceReplaceSupportResult | null> { return null; }
async findSectionHeaders(uri: URI): Promise<SectionHeader[]> { return []; }
async computeDefaultDocumentColors(uri: URI): Promise<IColorInformation[] | null> { return null; }
computeStringEditFromDiff(original: string, modified: string, options: { maxComputationTimeMs: number }, algorithm: DiffAlgorithmName): Promise<StringEdit> {
throw new Error('Method not implemented.');
}
}

View File

@ -53,6 +53,10 @@ export interface ITelemetryService {
setExperimentProperty(name: string, value: string): void;
}
export function telemetryLevelEnabled(service: ITelemetryService, level: TelemetryLevel): boolean {
return service.telemetryLevel >= level;
}
export interface ITelemetryEndpoint {
id: string;
aiKey: string;

View File

@ -0,0 +1,62 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { sumBy } from '../../../../base/common/arrays.js';
import { AnnotatedStringEdit, BaseStringEdit, IEditData } from '../../../../editor/common/core/edits/stringEdit.js';
/**
* The ARC (accepted and retained characters) counts how many characters inserted by the initial suggestion (trackedEdit)
* stay unmodified after a certain amount of time after acceptance.
*/
export class ArcTracker {
private _updatedTrackedEdit: AnnotatedStringEdit<IsTrackedEditData>;
constructor(
public readonly originalText: string,
private readonly _trackedEdit: BaseStringEdit,
) {
const eNormalized = _trackedEdit.removeCommonSuffixPrefix(originalText);
this._updatedTrackedEdit = eNormalized.mapData(() => new IsTrackedEditData(true));
}
handleEdits(edit: BaseStringEdit): void {
const e = edit.mapData(_d => new IsTrackedEditData(false));
const composedEdit = this._updatedTrackedEdit.compose(e);
const onlyTrackedEdit = composedEdit.decomposeSplit(e => !e.data.isTrackedEdit).e2;
this._updatedTrackedEdit = onlyTrackedEdit;
}
getAcceptedRestrainedCharactersCount(): number {
const s = sumBy(this._updatedTrackedEdit.replacements, e => e.getNewLength());
return s;
}
getOriginalCharacterCount(): number {
return sumBy(this._trackedEdit.replacements, e => e.getNewLength());
}
getDebugState(): unknown {
return {
edits: this._updatedTrackedEdit.replacements.map(e => ({
range: e.replaceRange.toString(),
newText: e.newText,
isTrackedEdit: e.data.isTrackedEdit,
}))
};
}
}
export class IsTrackedEditData implements IEditData<IsTrackedEditData> {
constructor(
public readonly isTrackedEdit: boolean
) { }
join(data: IsTrackedEditData): IsTrackedEditData | undefined {
if (this.isTrackedEdit !== data.isTrackedEdit) {
return undefined;
}
return this;
}
}

View File

@ -0,0 +1,424 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { AsyncIterableObject, raceTimeout } from '../../../../base/common/async.js';
import { CachedFunction } from '../../../../base/common/cache.js';
import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
import { IObservableWithChange, ISettableObservable, observableValue, RemoveUndefined, runOnChange } from '../../../../base/common/observable.js';
import { AnnotatedStringEdit, IEditData } from '../../../../editor/common/core/edits/stringEdit.js';
import { StringText } from '../../../../editor/common/core/text/abstractText.js';
import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js';
import { TextModelEditReason } from '../../../../editor/common/textModelEditReason.js';
import { IObservableDocument } from './observableWorkspace.js';
export interface IDocumentWithAnnotatedEdits<TEditData extends IEditData<TEditData> = EditSourceData> {
readonly value: IObservableWithChange<StringText, { edit: AnnotatedStringEdit<TEditData> }>;
waitForQueue(): Promise<void>;
}
/**
* Creates a document that is a delayed copy of the original document,
* but with edits annotated with the source of the edit.
*/
export class DocumentWithAnnotatedEdits extends Disposable implements IDocumentWithAnnotatedEdits<EditReasonData> {
public readonly value: IObservableWithChange<StringText, { edit: AnnotatedStringEdit<EditReasonData> }>;
constructor(private readonly _originalDoc: IObservableDocument) {
super();
const v = this.value = observableValue(this, _originalDoc.value.get());
this._register(runOnChange(this._originalDoc.value, (val, _prevVal, edits) => {
const eComposed = AnnotatedStringEdit.compose(edits.map(e => {
const editSourceData = new EditReasonData(e.reason);
return e.mapData(() => editSourceData);
}));
v.set(val, undefined, { edit: eComposed });
}));
}
public waitForQueue(): Promise<void> {
return Promise.resolve();
}
}
/**
* Only joins touching edits if the source and the metadata is the same.
*/
export class EditReasonData implements IEditData<EditReasonData> {
public readonly source;
public readonly key;
constructor(
public readonly editReason: TextModelEditReason
) {
this.key = this.editReason.toKey(1);
this.source = EditSourceBase.create(this.editReason);
}
join(data: EditReasonData): EditReasonData | undefined {
if (this.editReason !== data.editReason) {
return undefined;
}
return this;
}
toEditSourceData(): EditSourceData {
return new EditSourceData(this.key, this.source);
}
}
export class EditSourceData implements IEditData<EditSourceData> {
constructor(
public readonly key: string,
public readonly source: EditSource,
) { }
join(data: EditSourceData): EditSourceData | undefined {
if (this.key !== data.key) {
return undefined;
}
if (this.source !== data.source) {
return undefined;
}
return this;
}
}
export abstract class EditSourceBase {
private static _cache = new CachedFunction({ getCacheKey: v => v.toString() }, (arg: EditSource) => arg);
public static create(reason: TextModelEditReason): EditSource {
const data = reason.metadata;
switch (data.source) {
case 'reloadFromDisk':
return this._cache.get(new ExternalEditSource());
case 'inlineCompletionPartialAccept':
case 'inlineCompletionAccept': {
const type = 'type' in data ? data.type : undefined;
if ('$nes' in data && data.$nes) {
return this._cache.get(new InlineSuggestEditSource('nes', data.$extensionId ?? '', type));
}
return this._cache.get(new InlineSuggestEditSource('completion', data.$extensionId ?? '', type));
}
case 'snippet':
return this._cache.get(new IdeEditSource('suggest'));
case 'unknown':
if (!data.name) {
return this._cache.get(new UnknownEditSource());
}
switch (data.name) {
case 'formatEditsCommand':
return this._cache.get(new IdeEditSource('format'));
}
return this._cache.get(new UnknownEditSource());
case 'Chat.applyEdits':
return this._cache.get(new ChatEditSource('sidebar'));
case 'inlineChat.applyEdits':
return this._cache.get(new ChatEditSource('inline'));
case 'cursor':
return this._cache.get(new UserEditSource());
default:
return this._cache.get(new UnknownEditSource());
}
}
public abstract getColor(): string;
}
export type EditSource = InlineSuggestEditSource | ChatEditSource | IdeEditSource | UserEditSource | UnknownEditSource | ExternalEditSource;
export class InlineSuggestEditSource extends EditSourceBase {
public readonly category = 'ai';
public readonly feature = 'inlineSuggest';
constructor(
public readonly kind: 'completion' | 'nes',
public readonly extensionId: string,
public readonly type: 'word' | 'line' | undefined,
) { super(); }
override toString() { return `${this.category}/${this.feature}/${this.kind}/${this.extensionId}/${this.type}`; }
public getColor(): string { return '#00ff0033'; }
}
class ChatEditSource extends EditSourceBase {
public readonly category = 'ai';
public readonly feature = 'chat';
constructor(
public readonly kind: 'sidebar' | 'inline',
) { super(); }
override toString() { return `${this.category}/${this.feature}/${this.kind}`; }
public getColor(): string { return '#00ff0066'; }
}
class IdeEditSource extends EditSourceBase {
public readonly category = 'ide';
constructor(
public readonly feature: 'suggest' | 'format' | string,
) { super(); }
override toString() { return `${this.category}/${this.feature}`; }
public getColor(): string { return this.feature === 'format' ? '#0000ff33' : '#80808033'; }
}
class UserEditSource extends EditSourceBase {
public readonly category = 'user';
constructor() { super(); }
override toString() { return this.category; }
public getColor(): string { return '#d3d3d333'; }
}
/** Caused by external tools that trigger a reload from disk */
class ExternalEditSource extends EditSourceBase {
public readonly category = 'external';
constructor() { super(); }
override toString() { return this.category; }
public getColor(): string { return '#009ab254'; }
}
class UnknownEditSource extends EditSourceBase {
public readonly category = 'unknown';
constructor() { super(); }
override toString() { return this.category; }
public getColor(): string { return '#ff000033'; }
}
export class CombineStreamedChanges<TEditData extends EditSourceData & IEditData<TEditData>> extends Disposable implements IDocumentWithAnnotatedEdits<TEditData> {
private readonly _value: ISettableObservable<StringText, { edit: AnnotatedStringEdit<TEditData> }>;
readonly value: IObservableWithChange<StringText, { edit: AnnotatedStringEdit<TEditData> }>;
private readonly _runStore = this._register(new DisposableStore());
private _runQueue: Promise<void> = Promise.resolve();
constructor(
private readonly _originalDoc: IDocumentWithAnnotatedEdits<TEditData>,
@IEditorWorkerService private readonly _diffService: IEditorWorkerService,
) {
super();
this.value = this._value = observableValue(this, _originalDoc.value.get());
this._restart();
this._diffService.computeStringEditFromDiff('foo', 'last.value.value', { maxComputationTimeMs: 500 }, 'advanced');
}
async _restart(): Promise<void> {
this._runStore.clear();
const iterator = iterateChangesFromObservable(this._originalDoc.value, this._runStore)[Symbol.asyncIterator]();
const p = this._runQueue;
this._runQueue = this._runQueue.then(() => this._run(iterator));
await p;
}
private async _run(iterator: AsyncIterator<{ value: StringText; prevValue: StringText; change: { edit: AnnotatedStringEdit<TEditData> }[] }, any, any>) {
const reader = new AsyncReader(iterator);
while (true) {
let peeked = await reader.peek();
if (peeked === AsyncReaderEndOfStream) {
return;
} else if (isChatEdit(peeked)) {
const first = peeked;
let last = first;
let chatEdit = AnnotatedStringEdit.empty as AnnotatedStringEdit<TEditData>;
do {
reader.readSyncOrThrow();
last = peeked;
chatEdit = chatEdit.compose(AnnotatedStringEdit.compose(peeked.change.map(c => c.edit)));
if (!await reader.waitForBufferTimeout(1000)) {
break;
}
peeked = reader.peekSyncOrThrow();
} while (peeked !== AsyncReaderEndOfStream && isChatEdit(peeked));
if (!chatEdit.isEmpty()) {
const data = chatEdit.replacements[0].data;
const diffEdit = await this._diffService.computeStringEditFromDiff(first.prevValue.value, last.value.value, { maxComputationTimeMs: 500 }, 'advanced');
const edit = diffEdit.mapData(_e => data);
this._value.set(last.value, undefined, { edit });
}
} else {
reader.readSyncOrThrow();
const e = AnnotatedStringEdit.compose(peeked.change.map(c => c.edit));
this._value.set(peeked.value, undefined, { edit: e });
}
}
}
async waitForQueue(): Promise<void> {
await this._originalDoc.waitForQueue();
await this._restart();
}
}
function isChatEdit(next: { value: StringText; change: { edit: AnnotatedStringEdit<EditSourceData> }[] }) {
return next.change.every(c => c.edit.replacements.every(e => {
if (e.data.source.category === 'ai' && e.data.source.feature === 'chat') {
return true;
}
return false;
}));
}
function iterateChangesFromObservable<T, TChange>(obs: IObservableWithChange<T, TChange>, store: DisposableStore): AsyncIterable<{ value: T; prevValue: T; change: RemoveUndefined<TChange>[] }> {
return new AsyncIterableObject<{ value: T; prevValue: T; change: RemoveUndefined<TChange>[] }>((e) => {
store.add(runOnChange(obs, (value, prevValue, change) => {
e.emitOne({ value, prevValue, change: change });
}));
return new Promise((res) => {
store.add(toDisposable(() => {
res(undefined);
}));
});
});
}
export class MinimizeEditsProcessor<TEditData extends IEditData<TEditData>> extends Disposable implements IDocumentWithAnnotatedEdits<TEditData> {
readonly value: IObservableWithChange<StringText, { edit: AnnotatedStringEdit<TEditData> }>;
constructor(
private readonly _originalDoc: IDocumentWithAnnotatedEdits<TEditData>,
) {
super();
const v = this.value = observableValue(this, _originalDoc.value.get());
let prevValue: string = this._originalDoc.value.get().value;
this._register(runOnChange(this._originalDoc.value, (val, _prevVal, edits) => {
const eComposed = AnnotatedStringEdit.compose(edits.map(e => e.edit));
const e = eComposed.removeCommonSuffixAndPrefix(prevValue);
prevValue = val.value;
v.set(val, undefined, { edit: e });
}));
}
async waitForQueue(): Promise<void> {
await this._originalDoc.waitForQueue();
}
}
export const AsyncReaderEndOfStream = Symbol('AsyncReaderEndOfStream');
export class AsyncReader<T> {
private _buffer: T[] = [];
private _atEnd = false;
public get endOfStream(): boolean { return this._buffer.length === 0 && this._atEnd; }
constructor(
private readonly _source: AsyncIterator<T>
) {
}
private async _extendBuffer(): Promise<void> {
if (this._atEnd) {
return;
}
const { value, done } = await this._source.next();
if (done) {
this._atEnd = true;
} else {
this._buffer.push(value);
}
}
public async peek(): Promise<T | typeof AsyncReaderEndOfStream> {
if (this._buffer.length === 0 && !this._atEnd) {
await this._extendBuffer();
}
if (this._buffer.length === 0) {
return AsyncReaderEndOfStream;
}
return this._buffer[0];
}
public peekSyncOrThrow(): T | typeof AsyncReaderEndOfStream {
if (this._buffer.length === 0) {
if (this._atEnd) {
return AsyncReaderEndOfStream;
}
throw new Error('No more elements');
}
return this._buffer[0];
}
public readSyncOrThrow(): T | typeof AsyncReaderEndOfStream {
if (this._buffer.length === 0) {
if (this._atEnd) {
return AsyncReaderEndOfStream;
}
throw new Error('No more elements');
}
return this._buffer.shift()!;
}
public async peekNextTimeout(timeoutMs: number): Promise<T | typeof AsyncReaderEndOfStream | undefined> {
if (this._buffer.length === 0 && !this._atEnd) {
await raceTimeout(this._extendBuffer(), timeoutMs);
}
if (this._atEnd) {
return AsyncReaderEndOfStream;
}
if (this._buffer.length === 0) {
return undefined;
}
return this._buffer[0];
}
public async waitForBufferTimeout(timeoutMs: number): Promise<boolean> {
if (this._buffer.length > 0 || this._atEnd) {
return true;
}
const result = await raceTimeout(this._extendBuffer().then(() => true), timeoutMs);
return result !== undefined;
}
public async read(): Promise<T | typeof AsyncReaderEndOfStream> {
if (this._buffer.length === 0 && !this._atEnd) {
await this._extendBuffer();
}
if (this._buffer.length === 0) {
return AsyncReaderEndOfStream;
}
return this._buffer.shift()!;
}
public async readWhile(predicate: (value: T) => boolean, callback: (element: T) => unknown): Promise<void> {
do {
const piece = await this.peek();
if (piece === AsyncReaderEndOfStream) {
break;
}
if (!predicate(piece)) {
break;
}
await this.read(); // consume
await callback(piece);
} while (true);
}
public async consumeToEnd(): Promise<void> {
while (!this.endOfStream) {
await this.read();
}
}
}

View File

@ -0,0 +1,241 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CachedFunction } from '../../../../base/common/cache.js';
import { MarkdownString } from '../../../../base/common/htmlContent.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { autorun, mapObservableArrayCached, derived, IObservable, ISettableObservable, observableValue, derivedWithSetter, observableSignalFromEvent, observableFromEvent } from '../../../../base/common/observable.js';
import { isDefined } from '../../../../base/common/types.js';
import { URI } from '../../../../base/common/uri.js';
import { DynamicCssRules } from '../../../../editor/browser/editorDom.js';
import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js';
import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js';
import { IModelDeltaDecoration } from '../../../../editor/common/model.js';
import { CommandsRegistry } from '../../../../platform/commands/common/commands.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js';
import { EditorResourceAccessor } from '../../../common/editor.js';
import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';
import { IEditorService } from '../../../services/editor/common/editorService.js';
import { IStatusbarService, StatusbarAlignment } from '../../../services/statusbar/browser/statusbar.js';
import { EditSource } from './documentWithAnnotatedEdits.js';
import { EditSourceTrackingImpl } from './editSourceTrackingImpl.js';
import { EDIT_TELEMETRY_SHOW_DECORATIONS, EDIT_TELEMETRY_SHOW_STATUS_BAR } from './settings.js';
import { VSCodeWorkspace } from './vscodeObservableWorkspace.js';
export class EditTrackingFeature extends Disposable {
private readonly _editSourceTrackingShowDecorations;
private readonly _editSourceTrackingShowStatusBar;
private readonly _showStateInMarkdownDoc = 'editTelemetry.showDebugDetails';
private readonly _toggleDecorations = 'editTelemetry.toggleDebugDecorations';
constructor(
private readonly _workspace: VSCodeWorkspace,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IStatusbarService private readonly _statusbarService: IStatusbarService,
@IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService,
@IEditorService private readonly _editorService: IEditorService,
) {
super();
this._editSourceTrackingShowDecorations = makeSettable(observableConfigValue(EDIT_TELEMETRY_SHOW_DECORATIONS, false, this._configurationService));
this._editSourceTrackingShowStatusBar = observableConfigValue(EDIT_TELEMETRY_SHOW_STATUS_BAR, false, this._configurationService);
const onDidAddGroupSignal = observableSignalFromEvent(this, this._editorGroupsService.onDidAddGroup);
const onDidRemoveGroupSignal = observableSignalFromEvent(this, this._editorGroupsService.onDidRemoveGroup);
const groups = derived(this, reader => {
onDidAddGroupSignal.read(reader);
onDidRemoveGroupSignal.read(reader);
return this._editorGroupsService.groups;
});
const visibleUris: IObservable<Map<string, URI>> = mapObservableArrayCached(this, groups, g => {
const editors = observableFromEvent(this, g.onDidModelChange, () => g.editors);
return editors.map(e => e.map(editor => EditorResourceAccessor.getCanonicalUri(editor)));
}).map((editors, reader) => {
const map = new Map<string, URI>();
for (const urisObs of editors) {
for (const uri of urisObs.read(reader)) {
if (isDefined(uri)) {
map.set(uri.toString(), uri);
}
}
}
return map;
});
const impl = this._register(this._instantiationService.createInstance(EditSourceTrackingImpl, this._workspace, (doc, reader) => {
const map = visibleUris.read(reader);
return map.get(doc.uri.toString()) !== undefined;
}));
this._register(autorun((reader) => {
if (!this._editSourceTrackingShowDecorations.read(reader)) {
return;
}
const visibleEditors = observableFromEvent(this, this._editorService.onDidVisibleEditorsChange, () => this._editorService.visibleTextEditorControls);
mapObservableArrayCached(this, visibleEditors, (editor, store) => {
if (editor instanceof CodeEditorWidget) {
const obsEditor = observableCodeEditor(editor);
const cssStyles = new DynamicCssRules(editor);
const decorations = new CachedFunction((source: EditSource) => {
const r = store.add(cssStyles.createClassNameRef({
backgroundColor: source.getColor(),
}));
return r.className;
});
store.add(obsEditor.setDecorations(derived(reader => {
const uri = obsEditor.model.read(reader)?.uri;
if (!uri) { return []; }
const doc = this._workspace.getDocument(uri);
if (!doc) { return []; }
const docsState = impl.docsState.read(reader).get(doc);
if (!docsState) { return []; }
const ranges = (docsState.longtermTracker.read(reader)?.getTrackedRanges(reader)) ?? [];
return ranges.map<IModelDeltaDecoration>(r => ({
range: doc.value.get().getTransformer().getRange(r.range),
options: {
description: 'editSourceTracking',
inlineClassName: decorations.get(r.source),
}
}));
})));
}
}).recomputeInitiallyAndOnChange(reader.store);
}));
this._register(autorun(reader => {
if (!this._editSourceTrackingShowStatusBar.read(reader)) {
return;
}
const statusBarItem = reader.store.add(this._statusbarService.addEntry(
{
name: '',
text: '',
command: this._showStateInMarkdownDoc,
tooltip: 'Edit Source Tracking',
ariaLabel: '',
},
'editTelemetry',
StatusbarAlignment.RIGHT,
100
));
const sumChangedCharacters = derived(reader => {
const docs = impl.docsState.read(reader);
let sum = 0;
for (const state of docs.values()) {
const t = state.longtermTracker.read(reader);
if (!t) { continue; }
const d = state.getTelemetryData(t.getTrackedRanges(reader));
sum += d.totalModifiedCharactersInFinalState;
}
return sum;
});
const tooltipMarkdownString = derived(reader => {
const docs = impl.docsState.read(reader);
const docsDataInTooltip: string[] = [];
const editSources: EditSource[] = [];
for (const [doc, state] of docs) {
const tracker = state.longtermTracker.read(reader);
if (!tracker) {
continue;
}
const trackedRanges = tracker.getTrackedRanges(reader);
const data = state.getTelemetryData(trackedRanges);
if (data.totalModifiedCharactersInFinalState === 0) {
continue; // Don't include unmodified documents in tooltip
}
editSources.push(...trackedRanges.map(r => r.source));
// Filter out unmodified properties as these are not interesting to see in the hover
const filteredData = Object.fromEntries(
Object.entries(data).filter(([_, value]) => !(typeof value === 'number') || value !== 0)
);
docsDataInTooltip.push([
`### ${doc.uri.fsPath}`,
'```json',
JSON.stringify(filteredData, undefined, '\t'),
'```',
'\n'
].join('\n'));
}
let tooltipContent: string;
if (docsDataInTooltip.length === 0) {
tooltipContent = 'No modified documents';
} else if (docsDataInTooltip.length <= 3) {
tooltipContent = docsDataInTooltip.join('\n\n');
} else {
const lastThree = docsDataInTooltip.slice(-3);
tooltipContent = '...\n\n' + lastThree.join('\n\n');
}
const agenda = this._createEditSourceAgenda(editSources);
const tooltipWithCommand = new MarkdownString(tooltipContent + '\n\n[View Details](command:' + this._showStateInMarkdownDoc + ')');
tooltipWithCommand.appendMarkdown('\n\n' + agenda + '\n\nToggle decorations: [Click here](command:' + this._toggleDecorations + ')');
tooltipWithCommand.isTrusted = { enabledCommands: [this._toggleDecorations] };
tooltipWithCommand.supportHtml = true;
return tooltipWithCommand;
});
reader.store.add(autorun(reader => {
statusBarItem.update({
name: 'editTelemetry',
text: `$(edit) ${sumChangedCharacters.read(reader)} chars inserted`,
ariaLabel: `Edit Source Tracking: ${sumChangedCharacters.read(reader)} modified characters`,
tooltip: tooltipMarkdownString.read(reader),
command: this._showStateInMarkdownDoc,
});
}));
reader.store.add(CommandsRegistry.registerCommand(this._toggleDecorations, () => {
this._editSourceTrackingShowDecorations.set(!this._editSourceTrackingShowDecorations.get(), undefined);
}));
}));
}
private _createEditSourceAgenda(editSources: EditSource[]): string {
// Collect all edit sources from the tracked documents
const editSourcesSeen = new Set<string>();
const editSourceInfo = [];
for (const editSource of editSources) {
if (!editSourcesSeen.has(editSource.toString())) {
editSourcesSeen.add(editSource.toString());
editSourceInfo.push({ name: editSource.toString(), color: editSource.getColor() });
}
}
const agendaItems = editSourceInfo.map(info =>
`<span style="background-color:${info.color};border-radius:3px;">${info.name}</span>`
);
return agendaItems.join(' ');
}
}
export function makeSettable<T>(obs: IObservable<T>): ISettableObservable<T> {
const overrideObs = observableValue<T | undefined>('overrideObs', undefined);
return derivedWithSetter(overrideObs, (reader) => {
return overrideObs.read(reader) ?? obs.read(reader);
}, (value, tx) => {
overrideObs.set(value, tx);
});
}

View File

@ -0,0 +1,451 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { reverseOrder, compareBy, numberComparator, sumBy } from '../../../../base/common/arrays.js';
import { IntervalTimer, TimeoutTimer } from '../../../../base/common/async.js';
import { toDisposable, DisposableStore, Disposable } from '../../../../base/common/lifecycle.js';
import { mapObservableArrayCached, derived, IReader, IObservable, observableSignal, runOnChange, IObservableWithChange, observableValue, transaction, derivedObservableWithCache } from '../../../../base/common/observable.js';
import { isDefined } from '../../../../base/common/types.js';
import { URI } from '../../../../base/common/uri.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { AnnotatedStringEdit, BaseStringEdit } from '../../../../editor/common/core/edits/stringEdit.js';
import { StringText } from '../../../../editor/common/core/text/abstractText.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { ISCMRepository, ISCMService } from '../../scm/common/scm.js';
import { ArcTracker } from './arcTracker.js';
import { CombineStreamedChanges, DocumentWithAnnotatedEdits, EditReasonData, EditSource, EditSourceData, IDocumentWithAnnotatedEdits, MinimizeEditsProcessor } from './documentWithAnnotatedEdits.js';
import { DocumentEditSourceTracker, TrackedEdit } from './editTracker.js';
import { ObservableWorkspace, IObservableDocument } from './observableWorkspace.js';
export class EditSourceTrackingImpl extends Disposable {
public readonly docsState;
constructor(
private readonly _workspace: ObservableWorkspace,
private readonly _docIsVisible: (doc: IObservableDocument, reader: IReader) => boolean,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
) {
super();
const scmBridge = this._instantiationService.createInstance(ScmBridge);
this.docsState = mapObservableArrayCached(this, this._workspace.documents, (doc, store) => {
const docIsVisible = derived(reader => this._docIsVisible(doc, reader));
const wasEverVisible = derivedObservableWithCache<boolean>(this, (reader, lastVal) => lastVal || docIsVisible.read(reader));
return wasEverVisible.map(v => v ? [doc, store.add(this._instantiationService.createInstance(TrackedDocumentInfo, doc, docIsVisible, scmBridge))] as const : undefined);
}).recomputeInitiallyAndOnChange(this._store).map((entries, reader) => new Map(entries.map(e => e.read(reader)).filter(isDefined)));
}
}
class ScmBridge {
constructor(
@ISCMService private readonly _scmService: ISCMService
) { }
public async getRepo(uri: URI): Promise<ScmRepoBridge | undefined> {
const repo = this._scmService.getRepository(uri);
if (!repo) {
return undefined;
}
return new ScmRepoBridge(repo);
}
}
class ScmRepoBridge {
public readonly headBranchNameObs: IObservable<string | undefined> = derived(reader => this._repo.provider.historyProvider.read(reader)?.historyItemRef.read(reader)?.name);
public readonly headCommitHashObs: IObservable<string | undefined> = derived(reader => this._repo.provider.historyProvider.read(reader)?.historyItemRef.read(reader)?.revision);
constructor(
private readonly _repo: ISCMRepository,
) {
}
async isIgnored(uri: URI): Promise<boolean> {
return false;
}
}
class TrackedDocumentInfo extends Disposable {
public readonly longtermTracker: IObservable<DocumentEditSourceTracker<undefined> | undefined>;
public readonly windowedTracker: IObservable<DocumentEditSourceTracker<undefined> | undefined>;
private readonly _repo: Promise<ScmRepoBridge | undefined>;
constructor(
private readonly _doc: IObservableDocument,
docIsVisible: IObservable<boolean>,
private readonly _scm: ScmBridge,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@ITelemetryService private readonly _telemetryService: ITelemetryService
) {
super();
// Use the listener service and special events from core to annotate where an edit came from (is async)
let processedDoc: IDocumentWithAnnotatedEdits<EditReasonData> = this._store.add(new DocumentWithAnnotatedEdits(_doc));
// Combine streaming edits into one and make edit smaller
processedDoc = this._store.add(this._instantiationService.createInstance((CombineStreamedChanges<EditReasonData>), processedDoc));
// Remove common suffix and prefix from edits
processedDoc = this._store.add(new MinimizeEditsProcessor(processedDoc));
const docWithJustReason = createDocWithJustReason(processedDoc, this._store);
const longtermResetSignal = observableSignal('resetSignal');
this.longtermTracker = derived((reader) => {
longtermResetSignal.read(reader);
const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined));
reader.store.add(toDisposable(() => {
// send long term document telemetry
if (!t.isEmpty()) {
this.sendTelemetry('longterm', t.getTrackedRanges());
}
t.dispose();
}));
return t;
}).recomputeInitiallyAndOnChange(this._store);
this._store.add(new IntervalTimer()).cancelAndSet(() => {
// Reset after 10 hours
longtermResetSignal.trigger(undefined);
}, 10 * 60 * 60 * 1000);
(async () => {
const repo = await this._scm.getRepo(_doc.uri);
if (this._store.isDisposed) {
return;
}
// Reset on branch change or commit
if (repo) {
this._store.add(runOnChange(repo.headCommitHashObs, () => {
longtermResetSignal.trigger(undefined);
}));
this._store.add(runOnChange(repo.headBranchNameObs, () => {
longtermResetSignal.trigger(undefined);
}));
}
this._store.add(this._instantiationService.createInstance(ArcTelemetrySender, processedDoc, repo));
})();
const resetSignal = observableSignal('resetSignal');
this.windowedTracker = derived((reader) => {
if (!docIsVisible.read(reader)) {
return undefined;
}
resetSignal.read(reader);
reader.store.add(new TimeoutTimer(() => {
// Reset after 5 minutes
resetSignal.trigger(undefined);
}, 5 * 60 * 1000));
const t = reader.store.add(new DocumentEditSourceTracker(docWithJustReason, undefined));
reader.store.add(toDisposable(async () => {
// send long term document telemetry
this.sendTelemetry('5minWindow', t.getTrackedRanges());
t.dispose();
}));
return t;
}).recomputeInitiallyAndOnChange(this._store);
this._repo = this._scm.getRepo(_doc.uri);
}
async sendTelemetry(mode: 'longterm' | '5minWindow', ranges: readonly TrackedEdit[]) {
if (ranges.length === 0) {
return;
}
const data = this.getTelemetryData(ranges);
const isTrackedByGit = await data.isTrackedByGit;
const statsUuid = generateUuid();
this._telemetryService.publicLog2<{
mode: string;
languageId: string;
statsUuid: string;
nesModifiedCount: number;
inlineCompletionsCopilotModifiedCount: number;
inlineCompletionsNESModifiedCount: number;
otherAIModifiedCount: number;
unknownModifiedCount: number;
userModifiedCount: number;
ideModifiedCount: number;
totalModifiedCharacters: number;
externalModifiedCount: number;
isTrackedByGit: number;
}, {
owner: 'hediet';
comment: 'Reports distribution of AI vs user edited characters.';
mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'longterm or 5minWindow' };
languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' };
statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier for the telemetry event.' };
nesModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of nes modified characters'; isMeasurement: true };
inlineCompletionsCopilotModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of inline completions copilot modified characters'; isMeasurement: true };
inlineCompletionsNESModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of inline completions nes modified characters'; isMeasurement: true };
otherAIModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of other AI modified characters'; isMeasurement: true };
unknownModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of unknown modified characters'; isMeasurement: true };
userModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of user modified characters'; isMeasurement: true };
ideModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of IDE modified characters'; isMeasurement: true };
totalModifiedCharacters: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Total modified characters'; isMeasurement: true };
externalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of external modified characters'; isMeasurement: true };
isTrackedByGit: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates if the document is tracked by git.' };
}>('editTelemetry.editSources.stats', {
mode,
languageId: this._doc.languageId.get(),
statsUuid: statsUuid,
nesModifiedCount: data.nesModifiedCount,
inlineCompletionsCopilotModifiedCount: data.inlineCompletionsCopilotModifiedCount,
inlineCompletionsNESModifiedCount: data.inlineCompletionsNESModifiedCount,
otherAIModifiedCount: data.otherAIModifiedCount,
unknownModifiedCount: data.unknownModifiedCount,
userModifiedCount: data.userModifiedCount,
ideModifiedCount: data.ideModifiedCount,
totalModifiedCharacters: data.totalModifiedCharactersInFinalState,
externalModifiedCount: data.externalModifiedCount,
isTrackedByGit: isTrackedByGit ? 1 : 0,
});
const sums = sumByCategory(ranges, r => r.range.length, r => r.sourceKey);
const entries = Object.entries(sums).filter(([key, value]) => value !== undefined);
entries.sort(reverseOrder(compareBy(([key, value]) => value!, numberComparator)));
entries.length = mode === 'longterm' ? 30 : 10;
for (const [key, value] of Object.entries(sums)) {
if (value === undefined) {
continue;
}
this._telemetryService.publicLog2<{
mode: string;
reasonKey: string;
languageId: string;
statsUuid: string;
modifiedCount: number;
totalModifiedCount: number;
}, {
owner: 'hediet';
comment: 'Reports distribution of various edit kinds.';
reasonKey: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The reason for the edit.' };
mode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'longterm or 5minWindow' };
languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' };
statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier for the telemetry event.' };
modifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Fraction of nes modified characters'; isMeasurement: true };
totalModifiedCount: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Total number of characters'; isMeasurement: true };
}>('editTelemetry.editSources.details', {
mode,
reasonKey: key,
languageId: this._doc.languageId.get(),
statsUuid: statsUuid,
modifiedCount: value,
totalModifiedCount: data.totalModifiedCharactersInFinalState,
});
}
}
getTelemetryData(ranges: readonly TrackedEdit[]) {
const getEditCategory = (source: EditSource) => {
if (source.category === 'ai' && source.kind === 'nes') { return 'nes'; }
if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot') { return 'inlineCompletionsCopilot'; }
if (source.category === 'ai' && source.kind === 'completion' && source.extensionId === 'github.copilot-chat') { return 'inlineCompletionsNES'; }
if (source.category === 'ai' && source.kind === 'completion') { return 'inlineCompletionsOther'; }
if (source.category === 'ai') { return 'otherAI'; }
if (source.category === 'user') { return 'user'; }
if (source.category === 'ide') { return 'ide'; }
if (source.category === 'external') { return 'external'; }
if (source.category === 'unknown') { return 'unknown'; }
return 'unknown';
};
const sums = sumByCategory(ranges, r => r.range.length, r => getEditCategory(r.source));
const totalModifiedCharactersInFinalState = sumBy(ranges, r => r.range.length);
return {
nesModifiedCount: sums.nes ?? 0,
inlineCompletionsCopilotModifiedCount: sums.inlineCompletionsCopilot ?? 0,
inlineCompletionsNESModifiedCount: sums.inlineCompletionsNES ?? 0,
otherAIModifiedCount: sums.otherAI ?? 0,
userModifiedCount: sums.user ?? 0,
ideModifiedCount: sums.ide ?? 0,
unknownModifiedCount: sums.unknown ?? 0,
externalModifiedCount: sums.external ?? 0,
totalModifiedCharactersInFinalState,
languageId: this._doc.languageId.get(),
isTrackedByGit: this._repo.then(async (repo) => !!repo && !await repo.isIgnored(this._doc.uri)),
};
}
}
function mapObservableDelta<T, TDelta, TDeltaNew>(obs: IObservableWithChange<T, TDelta>, mapFn: (value: TDelta) => TDeltaNew, store: DisposableStore): IObservableWithChange<T, TDeltaNew> {
const obsResult = observableValue<T, TDeltaNew>('mapped', obs.get());
store.add(runOnChange(obs, (value, _prevValue, changes) => {
transaction(tx => {
for (const c of changes) {
obsResult.set(value, tx, mapFn(c));
}
});
}));
return obsResult;
}
/**
* Removing the metadata allows touching edits from the same source to merged, even if they were caused by different actions (e.g. two user edits).
*/
function createDocWithJustReason(docWithAnnotatedEdits: IDocumentWithAnnotatedEdits<EditReasonData>, store: DisposableStore): IDocumentWithAnnotatedEdits<EditSourceData> {
const docWithJustReason: IDocumentWithAnnotatedEdits<EditSourceData> = {
value: mapObservableDelta(docWithAnnotatedEdits.value, edit => ({ edit: edit.edit.mapData(d => d.data.toEditSourceData()) }), store),
waitForQueue: () => docWithAnnotatedEdits.waitForQueue(),
};
return docWithJustReason;
}
class ArcTelemetrySender extends Disposable {
constructor(
docWithAnnotatedEdits: IDocumentWithAnnotatedEdits<EditReasonData>,
scmRepoBridge: ScmRepoBridge | undefined,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
) {
super();
this._register(runOnChange(docWithAnnotatedEdits.value, (_val, _prev, changes) => {
const edit = AnnotatedStringEdit.compose(changes.map(c => c.edit));
if (edit.replacements.length !== 1) {
return;
}
const singleEdit = edit.replacements[0];
const data = singleEdit.data.editReason.metadata;
if (data?.source !== 'inlineCompletionAccept') {
return;
}
const docWithJustReason = createDocWithJustReason(docWithAnnotatedEdits, this._store);
const reporter = this._instantiationService.createInstance(ArcTelemetryReporter, docWithJustReason, scmRepoBridge, singleEdit.toEdit(), res => {
res.telemetryService.publicLog2<{
extensionId: string;
opportunityId: string;
didBranchChange: number;
timeDelayMs: number;
arc: number;
originalCharCount: number;
}, {
owner: 'hediet';
comment: 'Reports the accepted and retained character count for an inline completion/edit.';
extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The extension id (copilot or copilot-chat); which provided this inline completion.' };
opportunityId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Unique identifier for an opportunity to show an inline completion or NES.' };
didBranchChange: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates if the branch changed in the meantime. If the branch changed (value is 1); this event should probably be ignored.' };
timeDelayMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The time delay between the user accepting the edit and measuring the survival rate.' };
arc: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The accepted and restrained character count.' };
originalCharCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The original character count before any edits.' };
}>('editTelemetry.reportInlineEditArc', {
extensionId: data.$extensionId ?? '',
opportunityId: data.$$requestUuid ?? 'unknown',
didBranchChange: res.didBranchChange ? 1 : 0,
timeDelayMs: res.timeDelayMs,
arc: res.arc,
originalCharCount: res.originalCharCount,
});
});
this._register(toDisposable(() => {
reporter.cancel();
}));
}));
}
}
export interface EditTelemetryData {
telemetryService: ITelemetryService;
timeDelayMs: number;
didBranchChange: boolean;
arc: number;
originalCharCount: number;
}
export class ArcTelemetryReporter {
private readonly _store = new DisposableStore();
private readonly _arcTracker;
private readonly _initialBranchName: string | undefined;
constructor(
private readonly _document: { value: IObservableWithChange<StringText, { edit: BaseStringEdit }> },
// _markedEdits -> document.value
private readonly _gitRepo: ScmRepoBridge | undefined,
private readonly _trackedEdit: BaseStringEdit,
private readonly _sendTelemetryEvent: (res: EditTelemetryData) => void,
@ITelemetryService private readonly _telemetryService: ITelemetryService
) {
this._arcTracker = new ArcTracker(this._document.value.get().value, this._trackedEdit);
this._store.add(runOnChange(this._document.value, (_val, _prevVal, changes) => {
const edit = BaseStringEdit.composeOrUndefined(changes.map(c => c.edit));
if (edit) {
this._arcTracker.handleEdits(edit);
}
}));
this._initialBranchName = this._gitRepo?.headBranchNameObs.get();
// This aligns with github inline completions
this._reportAfter(30 * 1000);
this._reportAfter(120 * 1000);
this._reportAfter(300 * 1000);
this._reportAfter(600 * 1000);
// track up to 15min to allow for slower edit responses from legacy SD endpoint
this._reportAfter(900 * 1000, () => {
this._store.dispose();
});
}
private _reportAfter(timeoutMs: number, cb?: () => void) {
const timer = new TimeoutTimer(() => {
this._report(timeoutMs);
timer.dispose();
if (cb) {
cb();
}
}, timeoutMs);
this._store.add(timer);
}
private _report(timeMs: number): void {
const currentBranch = this._gitRepo?.headBranchNameObs.get();
const didBranchChange = currentBranch !== this._initialBranchName;
this._sendTelemetryEvent({
telemetryService: this._telemetryService,
timeDelayMs: timeMs,
didBranchChange,
arc: this._arcTracker.getAcceptedRestrainedCharactersCount(),
originalCharCount: this._arcTracker.getOriginalCharacterCount(),
});
}
public cancel(): void {
this._store.dispose();
}
}
function sumByCategory<T, TCategory extends string>(items: readonly T[], getValue: (item: T) => number, getCategory: (item: T) => TCategory): Record<TCategory, number | undefined> {
return items.reduce((acc, item) => {
const category = getCategory(item);
acc[category] = (acc[category] || 0) + getValue(item);
return acc;
}, {} as any as Record<TCategory, number>);
}

View File

@ -0,0 +1,41 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Registry } from '../../../../platform/registry/common/platform.js';
import { EditTelemetryService } from './editTelemetryService.js';
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
import { localize } from '../../../../nls.js';
import { EDIT_TELEMETRY_SETTING_ID, EDIT_TELEMETRY_SHOW_DECORATIONS, EDIT_TELEMETRY_SHOW_STATUS_BAR } from './settings.js';
import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js';
registerWorkbenchContribution2('EditTelemetryService', EditTelemetryService, WorkbenchPhase.AfterRestored);
const configurationRegistry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration);
configurationRegistry.registerConfiguration({
id: 'task',
order: 100,
title: localize('editTelemetry', "Edit Telemetry"),
type: 'object',
properties: {
[EDIT_TELEMETRY_SETTING_ID]: {
markdownDescription: localize('telemetry.editStats.enabled', "Controls whether to enable telemetry for edit statistics (only sends statistics if general telemetry is enabled)."),
type: 'boolean',
default: true,
tags: ['experimental'],
},
[EDIT_TELEMETRY_SHOW_STATUS_BAR]: {
markdownDescription: localize('telemetry.editStats.showStatusBar', "Controls whether to show the status bar for edit telemetry."),
type: 'boolean',
default: false,
tags: ['experimental'],
},
[EDIT_TELEMETRY_SHOW_DECORATIONS]: {
markdownDescription: localize('telemetry.editStats.showDecorations', "Controls whether to show decorations for edit telemetry."),
type: 'boolean',
default: false,
tags: ['experimental'],
},
}
});

View File

@ -0,0 +1,39 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../base/common/lifecycle.js';
import { autorun } from '../../../../base/common/observable.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js';
import { ITelemetryService, TelemetryLevel, telemetryLevelEnabled } from '../../../../platform/telemetry/common/telemetry.js';
import { EditTrackingFeature } from './editSourceTrackingFeature.js';
import { EDIT_TELEMETRY_SETTING_ID } from './settings.js';
import { VSCodeWorkspace } from './vscodeObservableWorkspace.js';
export class EditTelemetryService extends Disposable {
private readonly _editSourceTrackingEnabled;
constructor(
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IConfigurationService private readonly _configurationService: IConfigurationService,
@ITelemetryService private readonly _telemetryService: ITelemetryService,
) {
super();
this._editSourceTrackingEnabled = observableConfigValue(EDIT_TELEMETRY_SETTING_ID, true, this._configurationService);
this._register(autorun(r => {
const enabled = this._editSourceTrackingEnabled.read(r);
if (!enabled || !telemetryLevelEnabled(this._telemetryService, TelemetryLevel.USAGE)) {
return;
}
const workspace = this._instantiationService.createInstance(VSCodeWorkspace);
r.store.add(this._instantiationService.createInstance(EditTrackingFeature, workspace));
}));
}
}

View File

@ -0,0 +1,96 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable } from '../../../../base/common/lifecycle.js';
import { observableSignal, runOnChange, IReader } from '../../../../base/common/observable.js';
import { AnnotatedStringEdit } from '../../../../editor/common/core/edits/stringEdit.js';
import { OffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js';
import { IDocumentWithAnnotatedEdits, EditSourceData, EditSource } from './documentWithAnnotatedEdits.js';
/**
* Tracks a single document.
*/
export class DocumentEditSourceTracker<T = void> extends Disposable {
private _edits: AnnotatedStringEdit<EditSourceData> = AnnotatedStringEdit.empty;
private _pendingExternalEdits: AnnotatedStringEdit<EditSourceData> = AnnotatedStringEdit.empty;
private readonly _update = observableSignal(this);
constructor(
private readonly _doc: IDocumentWithAnnotatedEdits,
public readonly data: T,
) {
super();
this._register(runOnChange(this._doc.value, (_val, _prevVal, edits) => {
const eComposed = AnnotatedStringEdit.compose(edits.map(e => e.edit));
if (eComposed.replacements.every(e => e.data.source.category === 'external')) {
if (this._edits.isEmpty()) {
// Ignore initial external edits
} else {
// queue pending external edits
this._pendingExternalEdits = this._pendingExternalEdits.compose(eComposed);
}
} else {
if (!this._pendingExternalEdits.isEmpty()) {
this._edits = this._edits.compose(this._pendingExternalEdits);
this._pendingExternalEdits = AnnotatedStringEdit.empty;
}
this._edits = this._edits.compose(eComposed);
}
this._update.trigger(undefined);
}));
}
async waitForQueue(): Promise<void> {
await this._doc.waitForQueue();
}
getTrackedRanges(reader?: IReader): TrackedEdit[] {
this._update.read(reader);
const ranges = this._edits.getNewRanges();
return ranges.map((r, idx) => {
const e = this._edits.replacements[idx];
const reason = e.data.source;
const te = new TrackedEdit(e.replaceRange, r, reason, e.data.key);
return te;
});
}
isEmpty(): boolean {
return this._edits.isEmpty();
}
public reset(): void {
this._edits = AnnotatedStringEdit.empty;
}
public _getDebugVisualization() {
const ranges = this.getTrackedRanges();
const txt = this._doc.value.get().value;
return {
...{ $fileExtension: 'text.w' },
'value': txt,
'decorations': ranges.map(r => {
return {
range: [r.range.start, r.range.endExclusive],
color: r.source.getColor(),
};
})
};
}
}
export class TrackedEdit {
constructor(
public readonly originalRange: OffsetRange,
public readonly range: OffsetRange,
public readonly source: EditSource,
public readonly sourceKey: string,
) { }
}

View File

@ -0,0 +1,94 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IObservableWithChange, derivedHandleChanges, derivedWithStore, observableValue, autorunWithStore, runOnChange, IObservable } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { StringEdit } from '../../../../editor/common/core/edits/stringEdit.js';
import { StringText } from '../../../../editor/common/core/text/abstractText.js';
import { TextModelEditReason } from '../../../../editor/common/textModelEditReason.js';
export abstract class ObservableWorkspace {
abstract get documents(): IObservableWithChange<readonly IObservableDocument[]>;
getFirstOpenDocument(): IObservableDocument | undefined {
return this.documents.get()[0];
}
getDocument(documentId: URI): IObservableDocument | undefined {
return this.documents.get().find(d => d.uri.toString() === documentId.toString());
}
private _version = 0;
/**
* Is fired when any open document changes.
*/
public readonly onDidOpenDocumentChange = derivedHandleChanges({
owner: this,
changeTracker: {
createChangeSummary: () => ({ didChange: false }),
handleChange: (ctx, changeSummary) => {
if (!ctx.didChange(this.documents)) {
changeSummary.didChange = true; // A document changed
}
return true;
}
}
}, (reader, changeSummary) => {
const docs = this.documents.read(reader);
for (const d of docs) {
d.value.read(reader); // add dependency
}
if (changeSummary.didChange) {
this._version++; // to force a change
}
return this._version;
// TODO@hediet make this work:
/*
const docs = this.openDocuments.read(reader);
for (const d of docs) {
if (reader.readChangesSinceLastRun(d.value).length > 0) {
reader.reportChange(d);
}
}
return undefined;
*/
});
public readonly lastActiveDocument = derivedWithStore((_reader, store) => {
const obs = observableValue('lastActiveDocument', undefined as IObservableDocument | undefined);
store.add(autorunWithStore((reader, store) => {
const docs = this.documents.read(reader);
for (const d of docs) {
store.add(runOnChange(d.value, () => {
obs.set(d, undefined);
}));
}
}));
return obs;
}).flatten();
}
export interface IObservableDocument {
readonly uri: URI;
readonly value: IObservableWithChange<StringText, StringEditWithReason>;
/**
* Increases whenever the value changes. Is also used to reference document states from the past.
*/
readonly version: IObservable<number>;
readonly languageId: IObservable<string>;
}
export class StringEditWithReason extends StringEdit {
constructor(
replacements: StringEdit['replacements'],
public readonly reason: TextModelEditReason,
) {
super(replacements);
}
}

View File

@ -0,0 +1,8 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
export const EDIT_TELEMETRY_SETTING_ID = 'telemetry.editStats.enabled';
export const EDIT_TELEMETRY_SHOW_DECORATIONS = 'telemetry.editStats.showDecorations';
export const EDIT_TELEMETRY_SHOW_STATUS_BAR = 'telemetry.editStats.showStatusBar';

View File

@ -0,0 +1,91 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { onUnexpectedError } from '../../../../base/common/errors.js';
import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
import { derived, IObservable, IObservableWithChange, mapObservableArrayCached, observableSignalFromEvent, observableValue, transaction } from '../../../../base/common/observable.js';
import { isDefined } from '../../../../base/common/types.js';
import { URI } from '../../../../base/common/uri.js';
import { StringText } from '../../../../editor/common/core/text/abstractText.js';
import { ITextModel } from '../../../../editor/common/model.js';
import { offsetEditFromContentChanges } from '../../../../editor/common/model/textModelStringEdit.js';
import { IModelService } from '../../../../editor/common/services/model.js';
import { IObservableDocument, ObservableWorkspace, StringEditWithReason } from './observableWorkspace.js';
export class VSCodeWorkspace extends ObservableWorkspace implements IDisposable {
private readonly _documents;
public get documents() { return this._documents; }
private readonly _store = new DisposableStore();
constructor(
@IModelService private readonly _textModelService: IModelService,
) {
super();
const onModelAdded = observableSignalFromEvent(this, this._textModelService.onModelAdded);
const onModelRemoved = observableSignalFromEvent(this, this._textModelService.onModelRemoved);
const models = derived(this, reader => {
onModelAdded.read(reader);
onModelRemoved.read(reader);
const models = this._textModelService.getModels();
return models;
});
const documents = mapObservableArrayCached(this, models, (m, store) => {
if (m.isTooLargeForSyncing()) {
return undefined;
}
return store.add(new VSCodeDocument(m));
}).recomputeInitiallyAndOnChange(this._store).map(d => d.filter(isDefined));
this._documents = documents;
}
dispose(): void {
this._store.dispose();
}
}
export class VSCodeDocument extends Disposable implements IObservableDocument {
get uri(): URI { return this.textModel.uri; }
private readonly _value;
private readonly _version;
private readonly _languageId;
get value(): IObservableWithChange<StringText, StringEditWithReason> { return this._value; }
get version(): IObservable<number> { return this._version; }
get languageId(): IObservable<string> { return this._languageId; }
constructor(
public readonly textModel: ITextModel,
) {
super();
this._value = observableValue<StringText, StringEditWithReason>(this, new StringText(this.textModel.getValue()));
this._version = observableValue(this, this.textModel.getVersionId());
this._languageId = observableValue(this, this.textModel.getLanguageId());
this._register(this.textModel.onDidChangeContent((e) => {
transaction(tx => {
const edit = offsetEditFromContentChanges(e.changes);
if (e.detailedReasons.length !== 1) {
onUnexpectedError(new Error(`Unexpected number of detailed reasons: ${e.detailedReasons.length}`));
}
const change = new StringEditWithReason(edit.replacements, e.detailedReasons[0]);
this._value.set(new StringText(this.textModel.getValue()), tx, change);
this._version.set(this.textModel.getVersionId(), tx);
});
}));
this._register(this.textModel.onDidChangeLanguage(e => {
transaction(tx => {
this._languageId.set(this.textModel.getLanguageId(), tx);
});
}));
}
}

View File

@ -417,6 +417,8 @@ import './contrib/inlineCompletions/browser/inlineCompletions.contribution.js';
// Drop or paste into
import './contrib/dropOrPasteInto/browser/dropOrPasteInto.contribution.js';
// Edit Telemetry
import './contrib/editTelemetry/browser/editTelemetry.contribution.js';
//#endregion