Path: blob/main/extensions/copilot/src/extension/inlineEdits/node/nextEditCache.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';6import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId';7import { IObservableDocument, ObservableWorkspace } from '../../../platform/inlineEdits/common/observableWorkspace';8import { autorunWithChanges } from '../../../platform/inlineEdits/common/utils/observable';9import { ILogger, ILogService } from '../../../platform/log/common/logService';10import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';11import { LRUCache } from '../../../util/common/cache';12import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle';13import { mapObservableArrayCached } from '../../../util/vs/base/common/observableInternal';14import { AnnotatedStringReplacement, StringEdit, StringReplacement } from '../../../util/vs/editor/common/core/edits/stringEdit';15import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange';16import { StringText } from '../../../util/vs/editor/common/core/text/abstractText';17import { checkEditConsistency, EditDataWithIndex, NesRebaseConfigs, tryRebase } from '../common/editRebase';18import { NextEditFetchRequest } from './nextEditProvider';19import { RebaseFailureInfo, type RebaseResult } from './rebaseResult';2021export interface CachedEditOpts {22isFromCursorJump: boolean;23/**24* For cursor jump edits, this is the edit window around the original cursor position25* (before the jump), allowing the edit to be served from cache when the cursor is26* in either the original location or the jump target location.27*/28originalEditWindow?: OffsetRange;29/**30* The cursor offset at the time the edit was cached.31* Used for cursor-distance filtering: if the user moves farther from the edit,32* the cached entry is not served.33*/34cursorOffset?: number;35}3637export interface CachedEdit {38docId: DocumentId;39documentBeforeEdit: StringText;40editWindow?: OffsetRange;41/**42* For cursor jump edits, the edit window around the original cursor position.43* @see CachedEditOpts.originalEditWindow44*/45originalEditWindow?: OffsetRange;46edit: StringReplacement | undefined;47isFromCursorJump: boolean;48edits?: StringReplacement[];49detailedEdits: AnnotatedStringReplacement<EditDataWithIndex>[][];50userEditSince?: StringEdit;51rebaseFailed?: boolean;52rejected?: boolean;5354/**55* When caching multiple edits, this is the order in which they were applied.56*/57subsequentN?: number;58source: NextEditFetchRequest;59cacheTime: number;60/**61* The cursor offset at the time the edit was cached.62* @see CachedEditOpts.cursorOffset63*/64cursorOffsetAtCacheTime?: number;65/**66* Set to `true` once this cached suggestion has been rendered as an inline67* (ghost text at cursor) suggestion. Used by the "mimic ghost text behavior"68* gating to suppress re-serving the same suggestion in a non-inline form.69*/70wasRenderedAsInlineSuggestion?: boolean;71}7273export type CachedOrRebasedEdit = CachedEdit & {74rebasedEdit?: StringReplacement;75rebasedEditIndex?: number;76isFromSpeculativeRequest?: boolean;77/**78* When this is a rebased view of a cached edit, points to the underlying79* stored {@link CachedEdit} so that flags such as80* {@link CachedEdit.wasRenderedAsInlineSuggestion} can be persisted on the81* stable cache entry instead of the transient rebased view.82*/83baseCacheEntry?: CachedEdit;84};8586export class NextEditCache extends Disposable {87private readonly _documentCaches = new Map<DocumentId, DocumentEditCache>();88private readonly _sharedCache = new LRUCache<CachedEdit>(50);8990constructor(91public readonly workspace: ObservableWorkspace,92private readonly _logService: ILogService,93private readonly _configService: IConfigurationService,94private readonly _expService: IExperimentationService,95) {96super();9798mapObservableArrayCached(this, workspace.openDocuments, (doc, store) => {99const state = new DocumentEditCache(this, doc.id, doc, this._sharedCache, this._logService);100this._documentCaches.set(state.docId, state);101102store.add(autorunWithChanges(this, {103value: doc.value,104}, (data) => {105for (const edit of data.value.changes) {106if (!edit.isEmpty()) {107state.handleEdit(edit);108}109}110// if editor-change triggering is allowed,111// it means an edit in file A can result in a cached edit for file B to be less relevant than with the edits in file A included112if (this._configService.getExperimentBasedConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, this._expService) !== undefined) {113for (const [k, v] of this._sharedCache.entries()) {114if (v.docId !== doc.id) {115this._sharedCache.deleteKey(k);116}117}118}119}));120121store.add(toDisposable(() => {122this._documentCaches.delete(doc.id);123}));124}).recomputeInitiallyAndOnChange(this._store);125}126127public setKthNextEdit(docId: DocumentId, documentContents: StringText, editWindow: OffsetRange | undefined, nextEdit: StringReplacement, subsequentN: number, nextEdits: StringReplacement[] | undefined, userEditSince: StringEdit | undefined, source: NextEditFetchRequest, opts: CachedEditOpts): CachedEdit | undefined {128const docCache = this._documentCaches.get(docId);129if (!docCache) {130return;131}132return docCache.setKthNextEdit(documentContents, editWindow, nextEdit, nextEdits, userEditSince, subsequentN, source, opts);133}134135public setNoNextEdit(docId: DocumentId, documentContents: StringText, editWindow: OffsetRange | undefined, source: NextEditFetchRequest) {136const docCache = this._documentCaches.get(docId);137if (!docCache) {138return;139}140docCache.setNoNextEdit(documentContents, editWindow, source);141}142143private _getNesRebaseConfigs(): NesRebaseConfigs {144const maxImperfectAgreementLength = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsMaxImperfectAgreementLength, this._expService);145146return {147absorbSubsequenceTyping: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsAbsorbSubsequenceTyping, this._expService),148reverseAgreement: this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsReverseAgreement, this._expService),149maxImperfectAgreementLength: typeof maxImperfectAgreementLength === 'number' ? Math.max(0, maxImperfectAgreementLength) : maxImperfectAgreementLength,150};151}152153public lookupNextEdit(docId: DocumentId, currentDocumentContents: StringText, currentSelection: readonly OffsetRange[]): CachedOrRebasedEdit | undefined {154const docCache = this._documentCaches.get(docId);155if (!docCache) {156return undefined;157}158const cacheCursorDistanceCheck = this._configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsCacheCursorDistanceCheck, this._expService) ?? false;159return docCache.lookupNextEdit(currentDocumentContents, currentSelection, this._getNesRebaseConfigs(), cacheCursorDistanceCheck);160}161162public tryRebaseCacheEntry(cachedEdit: CachedEdit, currentDocumentContents: StringText, currentSelection: readonly OffsetRange[]): RebaseResult {163const docCache = this._documentCaches.get(cachedEdit.docId);164if (!docCache) {165return { edit: undefined };166}167return docCache.tryRebaseCacheEntry(cachedEdit, currentDocumentContents, currentSelection, this._getNesRebaseConfigs());168}169170public rejectedNextEdit(requestId: string): void {171this._sharedCache.getValues()172.filter(v => v.source.headerRequestId === requestId)173.forEach(v => v.rejected = true);174}175176public isRejectedNextEdit(docId: DocumentId, currentDocumentContents: StringText, edit: StringReplacement) {177const docCache = this._documentCaches.get(docId);178if (!docCache) {179return false;180}181return docCache.isRejectedNextEdit(currentDocumentContents, edit);182}183184public evictedCachedEdit(cachedEdit: CachedEdit) {185const docCache = this._documentCaches.get(cachedEdit.docId);186if (docCache) {187docCache.evictedCachedEdit(cachedEdit);188}189}190191public clear() {192this._documentCaches.forEach(cache => cache.clear());193this._sharedCache.clear();194}195}196197class DocumentEditCache {198199private readonly _trackedCachedEdits: CachedEdit[] = [];200private _logger: ILogger;201202constructor(203private readonly _nextEditCache: NextEditCache,204public readonly docId: DocumentId,205private readonly _doc: IObservableDocument,206private readonly _sharedCache: LRUCache<CachedEdit>,207_logService: ILogService,208) {209this._logger = _logService.createSubLogger(['NES', 'DocumentEditCache']);210}211212public handleEdit(edit: StringEdit): void {213const logger = this._logger.createSubLogger('handleEdit');214for (const cachedEdit of this._trackedCachedEdits) {215if (cachedEdit.userEditSince) {216cachedEdit.userEditSince = cachedEdit.userEditSince.compose(edit);217cachedEdit.rebaseFailed = false;218if (!checkEditConsistency(cachedEdit.documentBeforeEdit.value, cachedEdit.userEditSince, this._doc.value.get().value, logger)) {219cachedEdit.userEditSince = undefined;220}221}222}223}224225public evictedCachedEdit(cachedEdit: CachedEdit) {226const index = this._trackedCachedEdits.indexOf(cachedEdit);227if (index !== -1) {228this._trackedCachedEdits.splice(index, 1);229}230}231232public clear() {233this._trackedCachedEdits.length = 0;234}235236public setKthNextEdit(documentContents: StringText, editWindow: OffsetRange | undefined, nextEdit: StringReplacement, nextEdits: StringReplacement[] | undefined, userEditSince: StringEdit | undefined, subsequentN: number, source: NextEditFetchRequest, opts: CachedEditOpts): CachedEdit {237const key = this._getKey(documentContents.value);238const cachedEdit: CachedEdit = { docId: this.docId, edit: nextEdit, edits: nextEdits, detailedEdits: [], userEditSince, subsequentN, source, documentBeforeEdit: documentContents, editWindow, originalEditWindow: opts.originalEditWindow, cacheTime: Date.now(), isFromCursorJump: opts.isFromCursorJump, cursorOffsetAtCacheTime: opts.cursorOffset };239if (userEditSince) {240if (!checkEditConsistency(cachedEdit.documentBeforeEdit.value, userEditSince, this._doc.value.get().value, this._logger.createSubLogger('setKthNextEdit'))) {241cachedEdit.userEditSince = undefined;242} else {243this._trackedCachedEdits.unshift(cachedEdit);244}245}246const existing = this._sharedCache.get(key);247if (existing) {248this.evictedCachedEdit(existing);249}250const evicted = this._sharedCache.put(key, cachedEdit);251if (evicted) {252this._nextEditCache.evictedCachedEdit(evicted[1]);253}254return cachedEdit;255}256257public setNoNextEdit(documentContents: StringText, editWindow: OffsetRange | undefined, source: NextEditFetchRequest) {258const key = this._getKey(documentContents.value);259const cachedEdit: CachedEdit = { docId: this.docId, edit: undefined, edits: [], detailedEdits: [], source, documentBeforeEdit: documentContents, editWindow, cacheTime: Date.now(), isFromCursorJump: false };260const existing = this._sharedCache.get(key);261if (existing) {262this.evictedCachedEdit(existing);263}264const evicted = this._sharedCache.put(key, cachedEdit);265if (evicted) {266this._nextEditCache.evictedCachedEdit(evicted[1]);267}268}269270public lookupNextEdit(currentDocumentContents: StringText, currentSelection: readonly OffsetRange[], nesRebaseConfigs: NesRebaseConfigs, cacheCursorDistanceCheck: boolean = false): CachedOrRebasedEdit | undefined {271// TODO@chrmarti: Update entries i > 1 with user edits and edit window and start tracking.272const key = this._getKey(currentDocumentContents.value);273const cachedEdit = this._sharedCache.get(key);274if (cachedEdit) {275const editWindow = cachedEdit.editWindow;276const originalEditWindow = cachedEdit.originalEditWindow;277const cursorRange = currentSelection[0];278// For cursor jump edits, allow cache hits when cursor is in either the jump target window279// (editWindow) or the original cursor location window (originalEditWindow)280const inEditWindow = editWindow?.containsRange(cursorRange);281const inOriginalWindow = originalEditWindow?.containsRange(cursorRange);282if (editWindow && !inEditWindow && !inOriginalWindow) {283return undefined;284}285// If the cursor moved farther from the edit's start line than it was at cache time,286// reject the cached edit so the same suggestion is not shown again.287// Only applies to non-rebased, non-subsequent edits.288if (cacheCursorDistanceCheck289&& cachedEdit.edit290&& (cachedEdit.subsequentN === undefined || cachedEdit.subsequentN === 0)291&& cachedEdit.cursorOffsetAtCacheTime !== undefined292&& cursorRange293) {294const transformer = currentDocumentContents.getTransformer();295const editStartLine = transformer.getPosition(cachedEdit.edit.replaceRange.start).lineNumber;296const originalCursorLine = transformer.getPosition(cachedEdit.cursorOffsetAtCacheTime).lineNumber;297const currentCursorLine = transformer.getPosition(cursorRange.start).lineNumber;298if (Math.abs(currentCursorLine - editStartLine) > Math.abs(originalCursorLine - editStartLine)) {299cachedEdit.rejected = true;300return cachedEdit;301}302}303return cachedEdit;304}305for (const cachedEdit of this._trackedCachedEdits) {306const result = this.tryRebaseCacheEntry(cachedEdit, currentDocumentContents, currentSelection, nesRebaseConfigs);307if (result.edit) {308return result.edit;309}310}311return undefined;312}313314public tryRebaseCacheEntry(cachedEdit: CachedEdit, currentDocumentContents: StringText, currentSelection: readonly OffsetRange[], nesRebaseConfigs: NesRebaseConfigs): RebaseResult {315const logger = this._logger.createSubLogger('tryRebaseCacheEntry');316if (cachedEdit.userEditSince && !cachedEdit.rebaseFailed) {317const originalEdits = cachedEdit.edits || (cachedEdit.edit ? [cachedEdit.edit] : []);318319// For cursor jump edits, try rebasing with the primary edit window first.320// If that fails due to cursor being outside, try with the original edit window321// (the window around the cursor's original position before the jump).322const windowsToTry = cachedEdit.originalEditWindow323? [cachedEdit.editWindow, cachedEdit.originalEditWindow]324: [cachedEdit.editWindow];325326for (const window of windowsToTry) {327const res = tryRebase(cachedEdit.documentBeforeEdit.value, window, originalEdits, cachedEdit.detailedEdits, cachedEdit.userEditSince, currentDocumentContents.value, currentSelection, 'strict', logger, nesRebaseConfigs);328if (res === 'rebaseFailed') {329cachedEdit.rebaseFailed = true;330return {331edit: undefined,332failureInfo: new RebaseFailureInfo(cachedEdit.documentBeforeEdit.value, window, originalEdits, cachedEdit.userEditSince, currentDocumentContents.value, currentSelection, nesRebaseConfigs),333};334} else if (res === 'inconsistentEdits' || res === 'error') {335cachedEdit.userEditSince = undefined;336return { edit: undefined };337} else if (res === 'outsideEditWindow') {338// Try the next window (if available)339continue;340} else if (res.length) {341if (!cachedEdit.rejected && this.isRejectedNextEdit(currentDocumentContents, res[0].rebasedEdit)) {342cachedEdit.rejected = true;343}344return { edit: { ...cachedEdit, ...res[0], baseCacheEntry: cachedEdit } };345} else if (!originalEdits.length) {346return { edit: cachedEdit }; // cached 'no edits'347}348}349}350return { edit: undefined };351}352353public isRejectedNextEdit(currentDocumentContents: StringText, edit: StringReplacement) {354const logger = this._logger.createSubLogger('isRejectedNextEdit');355const resultEdit = edit.removeCommonSuffixAndPrefix(currentDocumentContents.value);356for (const rejectedEdit of this._trackedCachedEdits.filter(edit => edit.rejected)) {357if (!rejectedEdit.userEditSince) {358continue;359}360const edits = rejectedEdit.edits || (rejectedEdit.edit ? [rejectedEdit.edit] : []);361if (!edits.length) {362continue; // cached 'no edits'363}364const rejectedEdits = tryRebase(rejectedEdit.documentBeforeEdit.value, undefined, edits, rejectedEdit.detailedEdits, rejectedEdit.userEditSince, currentDocumentContents.value, [], 'lenient', logger);365if (typeof rejectedEdits === 'string') {366continue;367}368const rejected = rejectedEdits.some(rejected => rejected.rebasedEdit.removeCommonSuffixAndPrefix(currentDocumentContents.value).equals(resultEdit));369if (rejected) {370logger.trace('Found rejected edit that matches current edit');371return true;372}373}374return false;375}376377private _getKey(val: string): string {378return JSON.stringify([this.docId.uri, val]);379}380}381382383