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