Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineEditTriggerer.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 type * as vscode from 'vscode';
7
import { TextDocumentChangeReason } from 'vscode';
8
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
9
import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId';
10
import { DocumentSwitchTriggerStrategy } from '../../../platform/inlineEdits/common/dataTypes/triggerOptions';
11
import { ILogger, ILogService } from '../../../platform/log/common/logService';
12
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
13
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
14
import { isNotebookCell } from '../../../util/common/notebooks';
15
import { Emitter } from '../../../util/vs/base/common/event';
16
import { Disposable, DisposableMap, IDisposable, MutableDisposable } from '../../../util/vs/base/common/lifecycle';
17
import { generateUuid } from '../../../util/vs/base/common/uuid';
18
import { createTimeout } from '../common/common';
19
import { NesChangeHint, NesTriggerReason } from '../common/nesTriggerHint';
20
import { NesOutcome, NextEditProvider } from '../node/nextEditProvider';
21
import { VSCodeWorkspace } from './parts/vscodeWorkspace';
22
23
export const TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT = 10000; // 10 seconds
24
export const TRIGGER_INLINE_EDIT_ON_SAME_LINE_COOLDOWN = 5000; // milliseconds
25
export const TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN = 5000; // 5s
26
27
class LastChange extends Disposable {
28
public lastEditedTimestamp: number;
29
public lineNumberTriggers: Map<number /* lineNumber */, number /* timestamp */>;
30
31
public readonly timeout = this._register(new MutableDisposable<IDisposable>());
32
33
private _nConsecutiveSelectionChanges = 0;
34
public get nConsecutiveSelectionChanges(): number {
35
return this._nConsecutiveSelectionChanges;
36
}
37
public incrementSelectionChangeEventCount(): void {
38
this._nConsecutiveSelectionChanges++;
39
}
40
41
constructor(public documentTrigger: vscode.TextDocument) {
42
super();
43
this.lastEditedTimestamp = Date.now();
44
this.lineNumberTriggers = new Map();
45
}
46
}
47
48
export class InlineEditTriggerer extends Disposable {
49
50
private _onChangeEmitter = this._register(new Emitter<NesChangeHint>());
51
public readonly onChange = this._onChangeEmitter.event;
52
53
private readonly docToLastChangeMap = this._register(new DisposableMap<DocumentId, LastChange>());
54
55
private lastDocWithSelectionUri: string | undefined;
56
57
/**
58
* Timestamp of the last edit in any document.
59
*/
60
private lastEditTimestamp: number | undefined;
61
62
private readonly _logger: ILogger;
63
64
constructor(
65
private readonly workspace: VSCodeWorkspace,
66
private readonly nextEditProvider: NextEditProvider,
67
@ILogService private readonly _logService: ILogService,
68
@IConfigurationService private readonly _configurationService: IConfigurationService,
69
@IExperimentationService private readonly _expService: IExperimentationService,
70
@IWorkspaceService private readonly _workspaceService: IWorkspaceService
71
) {
72
super();
73
74
this._logger = this._logService.createSubLogger(['NES', 'Triggerer']);
75
76
this.registerListeners();
77
}
78
79
private registerListeners() {
80
this._registerDocumentChangeListener();
81
this._registerSelectionChangeListener();
82
}
83
84
private _shouldIgnoreDoc(doc: vscode.TextDocument): boolean {
85
return doc.uri.scheme === 'output'; // ignore output pane documents
86
}
87
88
private _registerDocumentChangeListener() {
89
this._register(this._workspaceService.onDidChangeTextDocument(e => {
90
if (this._shouldIgnoreDoc(e.document)) {
91
return;
92
}
93
94
this.lastEditTimestamp = Date.now();
95
96
const logger = this._logger.createSubLogger('onDidChangeTextDocument');
97
98
if (e.reason === TextDocumentChangeReason.Undo || e.reason === TextDocumentChangeReason.Redo) { // ignore
99
logger.trace('Return: undo/redo');
100
return;
101
}
102
103
const doc = this.workspace.getDocumentByTextDocument(e.document);
104
105
if (!doc) { // doc is likely copilot-ignored
106
logger.trace('Return: ignored document');
107
return;
108
}
109
110
this.docToLastChangeMap.set(doc.id, new LastChange(e.document));
111
112
logger.trace(`Return: updated last edit timestamp and cleared line triggers for document for ${doc.id.uri}`);
113
}));
114
}
115
116
private _registerSelectionChangeListener() {
117
this._register(this._workspaceService.onDidChangeTextEditorSelection(e => this._handleSelectionChange(e)));
118
}
119
120
private _handleSelectionChange(e: vscode.TextEditorSelectionChangeEvent) {
121
if (this._shouldIgnoreDoc(e.textEditor.document)) {
122
return;
123
}
124
125
const isSameDoc = this.lastDocWithSelectionUri === e.textEditor.document.uri.toString();
126
this.lastDocWithSelectionUri = e.textEditor.document.uri.toString();
127
128
const logger = this._logger.createSubLogger('onDidChangeTextEditorSelection');
129
130
if (e.selections.length !== 1) { // ignore multi-selection case
131
logger.trace('Return: multiple selections');
132
return;
133
}
134
135
if (!e.selections[0].isEmpty) { // ignore non-empty selection
136
logger.trace('Return: not empty selection');
137
return;
138
}
139
140
const doc = this.workspace.getDocumentByTextDocument(e.textEditor.document);
141
if (!doc) { // doc is likely copilot-ignored
142
return;
143
}
144
145
if (this._isWithinRejectionCooldown()) {
146
// the cursor has moved within 5s of the last rejection, don't auto-trigger until another doc modification
147
this.docToLastChangeMap.deleteAndDispose(doc.id);
148
logger.trace('Return: rejection cooldown');
149
return;
150
}
151
152
const mostRecentChange = this.docToLastChangeMap.get(doc.id);
153
if (!mostRecentChange) {
154
if (!this._maybeTriggerOnDocumentSwitch(e, isSameDoc, logger)) {
155
logger.trace('Return: document not tracked - does not have recent changes');
156
}
157
return;
158
}
159
160
// When the user switches to a different file and comes back, clear same-line
161
// cooldowns so the triggerer fires again on the same line.
162
if (!isSameDoc) {
163
mostRecentChange.lineNumberTriggers.clear();
164
}
165
166
const hadRecentEdit = this._hasRecentEdit(mostRecentChange);
167
if (!hadRecentEdit || !this._hasRecentTrigger()) {
168
// The edit is too old or the provider was not triggered recently (we might be
169
// observing a cursor change following an external edit) — try document switch.
170
const reason = hadRecentEdit ? 'no recent trigger' : 'no recent edit';
171
if (!this._maybeTriggerOnDocumentSwitch(e, isSameDoc, logger)) {
172
logger.trace(`Return: ${reason}`);
173
}
174
return;
175
}
176
177
this._handleTrackedDocSelectionChange(e, doc, mostRecentChange, logger);
178
}
179
180
/**
181
* Handles a selection change in a document that has a recent tracked edit and a recent NES trigger.
182
* Checks same-line cooldown, cleans up stale triggers, and fires the trigger (possibly debounced).
183
*/
184
private _handleTrackedDocSelectionChange(
185
e: vscode.TextEditorSelectionChangeEvent,
186
doc: NonNullable<ReturnType<VSCodeWorkspace['getDocumentByTextDocument']>>,
187
mostRecentChange: LastChange,
188
logger: ILogger,
189
) {
190
const range = doc.toRange(e.textEditor.document, e.selections[0]);
191
if (!range) {
192
logger.trace('Return: no range');
193
return;
194
}
195
196
const selectionLine = range.start.line;
197
198
if (this._isSameLineCooldownActive(mostRecentChange, selectionLine, e.textEditor.document)) {
199
logger.trace('Return: same line cooldown');
200
return;
201
}
202
203
// TODO: Do not trigger if there is an existing valid request now running, ie don't use just last-trigger timestamp
204
this._cleanupStaleLineTriggers(mostRecentChange);
205
206
mostRecentChange.lineNumberTriggers.set(selectionLine, Date.now());
207
mostRecentChange.documentTrigger = e.textEditor.document;
208
logger.trace('Return: triggering inline edit');
209
210
this._triggerWithDebounce(mostRecentChange);
211
}
212
213
// #region Helper predicates
214
215
private _isWithinRejectionCooldown(): boolean {
216
return (Date.now() - this.nextEditProvider.lastRejectionTime) < TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN;
217
}
218
219
private _hasRecentEdit(mostRecentChange: LastChange): boolean {
220
return (Date.now() - mostRecentChange.lastEditedTimestamp) < TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT;
221
}
222
223
private _hasRecentTrigger(): boolean {
224
return (Date.now() - this.nextEditProvider.lastTriggerTime) < TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT;
225
}
226
227
/**
228
* Returns true if the same-line cooldown is active and we should skip triggering.
229
*
230
* The cooldown is bypassed when we're in a notebook cell and the current document
231
* differs from the one that originally triggered the change (user moved to a
232
* different cell).
233
*
234
* When the user switches to a different file and comes back, line triggers are
235
* cleared (see {@link _handleSelectionChange}), so the cooldown naturally resets.
236
*/
237
private _isSameLineCooldownActive(mostRecentChange: LastChange, selectionLine: number, currentDocument: vscode.TextDocument): boolean {
238
// In a notebook, if the user moved to a different cell, bypass the cooldown
239
if (isNotebookCell(currentDocument.uri) && currentDocument !== mostRecentChange.documentTrigger) {
240
return false; // cooldown bypassed
241
}
242
243
const lastTriggerTimestampForLine = mostRecentChange.lineNumberTriggers.get(selectionLine);
244
return lastTriggerTimestampForLine !== undefined
245
&& (Date.now() - lastTriggerTimestampForLine) < TRIGGER_INLINE_EDIT_ON_SAME_LINE_COOLDOWN;
246
}
247
248
// #endregion
249
250
// #region Trigger helpers
251
252
/**
253
* Removes line triggers older than {@link TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT}
254
* when the map grows beyond 100 entries.
255
*/
256
private _cleanupStaleLineTriggers(mostRecentChange: LastChange): void {
257
if (mostRecentChange.lineNumberTriggers.size <= 100) {
258
return;
259
}
260
const now = Date.now();
261
for (const [lineNumber, timestamp] of mostRecentChange.lineNumberTriggers.entries()) {
262
if (now - timestamp > TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT) {
263
mostRecentChange.lineNumberTriggers.delete(lineNumber);
264
}
265
}
266
}
267
268
/**
269
* Fires a selection-change trigger, applying debounce when configured.
270
*
271
* The first 2 selection changes after an edit fire immediately (the 1st is caused by
272
* the edit itself, the 2nd is the user intentionally moving to the next edit location).
273
* Subsequent changes are debounced to avoid excessive triggering during rapid navigation.
274
*/
275
private _triggerWithDebounce(mostRecentChange: LastChange): void {
276
const debounceMs = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, this._expService);
277
if (debounceMs === undefined) {
278
this._triggerInlineEdit(NesTriggerReason.SelectionChange);
279
return;
280
}
281
282
const N_ALLOWED_IMMEDIATE_SELECTION_CHANGE_EVENTS = 2;
283
if (mostRecentChange.nConsecutiveSelectionChanges < N_ALLOWED_IMMEDIATE_SELECTION_CHANGE_EVENTS) {
284
this._triggerInlineEdit(NesTriggerReason.SelectionChange);
285
} else {
286
mostRecentChange.timeout.value = createTimeout(debounceMs, () => this._triggerInlineEdit(NesTriggerReason.SelectionChange));
287
}
288
mostRecentChange.incrementSelectionChangeEventCount();
289
}
290
291
// #endregion
292
293
private _maybeTriggerOnDocumentSwitch(e: vscode.TextEditorSelectionChangeEvent, isSameDoc: boolean, parentLogger: ILogger): boolean {
294
const logger = parentLogger.createSubLogger('editorSwitch');
295
const triggerAfterSeconds = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, this._expService);
296
if (triggerAfterSeconds === undefined) {
297
logger.trace('document switch disabled');
298
return false;
299
}
300
if (isSameDoc) {
301
logger.trace(`Return: document switch didn't happen`);
302
return false;
303
}
304
if (this.lastEditTimestamp === undefined) {
305
logger.trace('Return: no last edit timestamp');
306
return false;
307
}
308
const now = Date.now();
309
const triggerThresholdMs = triggerAfterSeconds * 1000;
310
const timeSinceLastEdit = now - this.lastEditTimestamp;
311
if (timeSinceLastEdit > triggerThresholdMs) {
312
logger.trace('Return: too long since last edit');
313
return false;
314
}
315
316
// Require a recent NES trigger before triggering on document switch.
317
// lastTriggerTime === 0 means NES was never triggered in this session.
318
const timeSinceLastTrigger = now - this.nextEditProvider.lastTriggerTime;
319
if (this.nextEditProvider.lastTriggerTime === 0 || timeSinceLastTrigger > triggerThresholdMs) {
320
logger.trace('Return: no recent NES trigger');
321
return false;
322
}
323
324
const strategy = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsTriggerOnEditorChangeStrategy, this._expService);
325
if (strategy === DocumentSwitchTriggerStrategy.AfterAcceptance && this.nextEditProvider.lastOutcome !== NesOutcome.Accepted) {
326
// When the afterAcceptance strategy is active, only trigger on document switch
327
// if the most recent NES was accepted. A pending outcome (undefined) is treated
328
// as not-accepted to avoid racing with the UI's accept/reject/ignore callback.
329
logger.trace('Return: afterAcceptance strategy requires last NES to be accepted');
330
return false;
331
}
332
333
const doc = this.workspace.getDocumentByTextDocument(e.textEditor.document);
334
if (!doc) { // doc is likely copilot-ignored
335
logger.trace('Return: ignored document');
336
return false;
337
}
338
339
const range = doc.toRange(e.textEditor.document, e.selections[0]);
340
if (!range) {
341
logger.trace('Return: no range');
342
return false;
343
}
344
345
const selectionLine = range.start.line;
346
347
// mark as touched such that NES gets triggered on cursor move; otherwise, user may get a single NES then move cursor and never get the suggestion back
348
const lastChange = new LastChange(e.textEditor.document);
349
lastChange.lineNumberTriggers.set(selectionLine, Date.now());
350
this.docToLastChangeMap.set(doc.id, lastChange);
351
352
this._triggerInlineEdit(NesTriggerReason.ActiveDocumentSwitch);
353
return true;
354
}
355
356
private _triggerInlineEdit(reason: NesTriggerReason) {
357
const uuid = generateUuid();
358
this._logger.trace(`Triggering inline edit: ${reason}`);
359
this._onChangeEmitter.fire({ data: { uuid, reason } });
360
}
361
}
362
363