Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/test/vscode-node/inlineEditTriggerer.spec.ts
13405 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 { afterEach, assert, beforeEach, suite, test } from 'vitest';
7
import { TextDocumentChangeReason, TextEditor, type TextDocument } from 'vscode';
8
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
9
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
10
import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/documentId';
11
import { DocumentSwitchTriggerStrategy } from '../../../../platform/inlineEdits/common/dataTypes/triggerOptions';
12
import { ILogService } from '../../../../platform/log/common/logService';
13
import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';
14
import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspaceService';
15
import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument';
16
import { ExtHostTextEditor } from '../../../../util/common/test/shims/textEditor';
17
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
18
import { IReader } from '../../../../util/vs/base/common/observableInternal';
19
import { Selection, TextEditorSelectionChangeKind, Uri } from '../../../../vscodeTypes';
20
import { createExtensionUnitTestingServices } from '../../../test/node/services';
21
import { NesChangeHint, NesTriggerReason } from '../../common/nesTriggerHint';
22
import { NesOutcome, NextEditProvider } from '../../node/nextEditProvider';
23
import {
24
InlineEditTriggerer,
25
TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT,
26
TRIGGER_INLINE_EDIT_ON_SAME_LINE_COOLDOWN,
27
TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN
28
} from '../../vscode-node/inlineEditTriggerer';
29
import { IVSCodeObservableDocument } from '../../vscode-node/parts/vscodeWorkspace';
30
31
32
suite('InlineEditTriggerer', () => {
33
let disposables: DisposableStore;
34
let vscWorkspace: MockVSCodeWorkspace;
35
let workspaceService: TestWorkspaceService;
36
let firedEvents: NesChangeHint[];
37
let nextEditProvider: MockNextEditProvider;
38
let configurationService: InMemoryConfigurationService;
39
let triggerer: InlineEditTriggerer;
40
41
class MockNextEditProvider {
42
public lastRejectionTime: number = Date.now();
43
public lastTriggerTime: number = Date.now();
44
public lastOutcome: NesOutcome | undefined = undefined;
45
}
46
47
class MockVSCodeWorkspace {
48
public readonly documents = new WeakMap<TextDocument, IVSCodeObservableDocument>();
49
public addDoc(doc: TextDocument, obsDoc: IVSCodeObservableDocument): void {
50
this.documents.set(doc, obsDoc);
51
}
52
public getDocumentByTextDocument(doc: TextDocument, _reader?: IReader): IVSCodeObservableDocument | undefined {
53
return this.documents.get(doc);
54
}
55
}
56
57
beforeEach(() => {
58
disposables = new DisposableStore();
59
firedEvents = [];
60
vscWorkspace = new MockVSCodeWorkspace();
61
nextEditProvider = new MockNextEditProvider();
62
63
workspaceService = disposables.add(new TestWorkspaceService());
64
const services = disposables.add(createExtensionUnitTestingServices());
65
const accessor = disposables.add(services.createTestingAccessor());
66
67
configurationService = accessor.get(IConfigurationService) as InMemoryConfigurationService;
68
triggerer = disposables.add(new InlineEditTriggerer(
69
vscWorkspace as any,
70
nextEditProvider as any as NextEditProvider,
71
accessor.get(ILogService),
72
configurationService,
73
accessor.get(IExperimentationService),
74
workspaceService
75
));
76
disposables.add(triggerer.onChange(e => firedEvents.push(e)));
77
});
78
79
afterEach(() => {
80
disposables.dispose();
81
});
82
83
// #region Helper functions
84
85
function triggerTextChange(document: TextDocument, reason?: TextDocumentChangeReason): void {
86
workspaceService.didChangeTextDocumentEmitter.fire({
87
document,
88
contentChanges: [],
89
reason,
90
detailedReason: undefined,
91
});
92
}
93
94
function triggerTextSelectionChange(textEditor: TextEditor, selection: Selection, kind = TextEditorSelectionChangeKind.Keyboard): void {
95
workspaceService.didChangeTextEditorSelectionEmitter.fire({
96
kind,
97
selections: [selection],
98
textEditor,
99
});
100
}
101
102
function triggerMultipleSelectionChange(textEditor: TextEditor, selections: Selection[]): void {
103
workspaceService.didChangeTextEditorSelectionEmitter.fire({
104
kind: TextEditorSelectionChangeKind.Keyboard,
105
selections,
106
textEditor,
107
});
108
}
109
110
function createObservableTextDoc(uri: Uri): IVSCodeObservableDocument {
111
return {
112
id: DocumentId.create(uri.toString()),
113
toRange: (_: any, range: any) => range
114
} as any;
115
}
116
117
function createTextDocument(
118
selection: Selection = new Selection(0, 0, 0, 0),
119
uri: Uri = Uri.file('sample.py'),
120
content = 'print("Hello World")'
121
): { document: TextDocument; textEditor: TextEditor; selection: Selection } {
122
const doc = createTextDocumentData(uri, content, 'python');
123
const textEditor = new ExtHostTextEditor(doc.document, [selection], {}, [], undefined);
124
vscWorkspace.addDoc(doc.document, createObservableTextDoc(doc.document.uri));
125
return {
126
document: doc.document,
127
textEditor: textEditor.value,
128
selection
129
};
130
}
131
132
function createOutputDocument(): { document: TextDocument; textEditor: TextEditor; selection: Selection } {
133
const uri = Uri.parse('output:extension-output-GitHub.copilot-chat-#1-GitHub Copilot Chat');
134
const doc = createTextDocumentData(uri, 'output logs', 'log');
135
const selection = new Selection(0, 0, 0, 0);
136
const textEditor = new ExtHostTextEditor(doc.document, [selection], {}, [], undefined);
137
return { document: doc.document, textEditor: textEditor.value, selection };
138
}
139
140
function getLastFiredReason(): NesTriggerReason | undefined {
141
return firedEvents.at(-1)?.data.reason;
142
}
143
144
// #endregion
145
146
// #region Basic behaviors
147
148
suite('Basic behaviors', () => {
149
test('No signal if there were no text changes', () => {
150
const { textEditor, selection } = createTextDocument();
151
152
triggerTextSelectionChange(textEditor, selection);
153
154
assert.strictEqual(firedEvents.length, 0, 'Signal should not have been fired');
155
});
156
157
test('No signal if selection is not empty', () => {
158
const { document, textEditor, selection } = createTextDocument(new Selection(0, 0, 0, 10));
159
160
triggerTextChange(document);
161
triggerTextSelectionChange(textEditor, selection);
162
163
assert.strictEqual(firedEvents.length, 0, 'Signal should not have been fired');
164
});
165
166
test('Signal fires when text changes and cursor moves with empty selection', () => {
167
const { document, textEditor } = createTextDocument();
168
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
169
170
triggerTextChange(document);
171
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
172
173
assert.isAtLeast(firedEvents.length, 1, 'Signal should have been fired');
174
assert.strictEqual(getLastFiredReason(), NesTriggerReason.SelectionChange);
175
});
176
177
test('No signal with multiple selections', () => {
178
const { document, textEditor } = createTextDocument();
179
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
180
181
triggerTextChange(document);
182
triggerMultipleSelectionChange(textEditor, [
183
new Selection(0, 0, 0, 0),
184
new Selection(1, 0, 1, 0)
185
]);
186
187
assert.strictEqual(firedEvents.length, 0, 'Signal should not have been fired for multiple selections');
188
});
189
});
190
191
// #endregion
192
193
// #region Rejection cooldown
194
195
suite('Rejection cooldown', () => {
196
test('No signal when last rejection was within cooldown period', () => {
197
const { document, textEditor } = createTextDocument();
198
nextEditProvider.lastRejectionTime = Date.now() - (TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1000);
199
200
triggerTextChange(document);
201
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
202
203
assert.strictEqual(firedEvents.length, 0, 'Signal should not fire during rejection cooldown');
204
});
205
206
test('Signal fires when last rejection was over cooldown ago', () => {
207
const { document, textEditor } = createTextDocument();
208
nextEditProvider.lastRejectionTime = Date.now() - (TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN + 1);
209
210
triggerTextChange(document);
211
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
212
213
assert.isAtLeast(firedEvents.length, 1, 'Signal should have been fired');
214
});
215
216
test('Rejection clears tracking for the document', () => {
217
const { document, textEditor } = createTextDocument();
218
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
219
220
triggerTextChange(document);
221
// Now set rejection time to be recent
222
nextEditProvider.lastRejectionTime = Date.now();
223
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
224
225
assert.strictEqual(firedEvents.length, 0, 'Signal should not fire');
226
227
// Make another change and ensure tracking was cleared
228
triggerTextSelectionChange(textEditor, new Selection(0, 10, 0, 10));
229
assert.strictEqual(firedEvents.length, 0, 'Signal should still not fire as doc was cleared');
230
});
231
});
232
233
// #endregion
234
235
// #region Document filtering
236
237
suite('Document filtering', () => {
238
test('Ignores output pane documents for text changes', () => {
239
const { document, textEditor, selection } = createOutputDocument();
240
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
241
242
triggerTextChange(document);
243
triggerTextSelectionChange(textEditor, selection);
244
245
assert.strictEqual(firedEvents.length, 0, 'Signal should not fire for output documents');
246
});
247
248
test('Ignores copilot-ignored documents (not in workspace)', () => {
249
const { document, textEditor } = createTextDocument();
250
// Remove from workspace to simulate copilot-ignored
251
vscWorkspace.documents.delete(document);
252
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
253
254
triggerTextChange(document);
255
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
256
257
assert.strictEqual(firedEvents.length, 0, 'Signal should not fire for ignored documents');
258
});
259
});
260
261
// #endregion
262
263
// #region Undo/Redo handling
264
265
suite('Undo/Redo handling', () => {
266
test('Ignores undo changes', () => {
267
const { document, textEditor } = createTextDocument();
268
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
269
270
triggerTextChange(document, TextDocumentChangeReason.Undo);
271
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
272
273
assert.strictEqual(firedEvents.length, 0, 'Signal should not fire for undo changes');
274
});
275
276
test('Ignores redo changes', () => {
277
const { document, textEditor } = createTextDocument();
278
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
279
280
triggerTextChange(document, TextDocumentChangeReason.Redo);
281
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
282
283
assert.strictEqual(firedEvents.length, 0, 'Signal should not fire for redo changes');
284
});
285
});
286
287
// #endregion
288
289
// #region Edit timestamp limits
290
291
suite('Edit timestamp limits', () => {
292
test('No signal if edit is too old', async () => {
293
const { document } = createTextDocument();
294
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
295
296
triggerTextChange(document);
297
298
// Simulate time passing beyond the limit by manipulating internal state
299
// We need to wait for the limit to pass - but since we can't easily mock Date.now(),
300
// we test the boundary condition instead by verifying the constant is used correctly
301
assert.strictEqual(TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT, 10000, 'Limit should be 10 seconds');
302
});
303
304
test('Signal fires when edit is within time limit', () => {
305
const { document, textEditor } = createTextDocument();
306
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
307
308
triggerTextChange(document);
309
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
310
311
assert.isAtLeast(firedEvents.length, 1, 'Signal should fire for recent edits');
312
});
313
});
314
315
// #endregion
316
317
// #region Trigger time checks
318
319
suite('Trigger time checks', () => {
320
test('No signal if last trigger time is too old', () => {
321
const { document, textEditor } = createTextDocument();
322
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
323
nextEditProvider.lastTriggerTime = Date.now() - TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT - 1;
324
325
triggerTextChange(document);
326
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
327
328
assert.strictEqual(firedEvents.length, 0, 'Signal should not fire when last trigger is too old');
329
});
330
331
test('Signal fires when last trigger time is recent', () => {
332
const { document, textEditor } = createTextDocument();
333
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
334
nextEditProvider.lastTriggerTime = Date.now();
335
336
triggerTextChange(document);
337
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
338
339
assert.isAtLeast(firedEvents.length, 1, 'Signal should fire for recent triggers');
340
});
341
});
342
343
// #endregion
344
345
// #region Same line cooldown
346
347
suite('Same line cooldown', () => {
348
test('No signal for same line within cooldown period', () => {
349
const { document, textEditor } = createTextDocument();
350
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
351
352
triggerTextChange(document);
353
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
354
355
const initialCount = firedEvents.length;
356
assert.isAtLeast(initialCount, 1, 'First signal should fire');
357
358
// Same line, different column - should be in cooldown
359
triggerTextSelectionChange(textEditor, new Selection(0, 10, 0, 10));
360
361
assert.strictEqual(firedEvents.length, initialCount, 'Signal should not fire for same line in cooldown');
362
});
363
364
test('Signal fires on different line', () => {
365
const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3');
366
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
367
368
triggerTextChange(document);
369
triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));
370
371
const initialCount = firedEvents.length;
372
assert.isAtLeast(initialCount, 1, 'First signal should fire');
373
374
// Different line
375
triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));
376
377
assert.isAtLeast(firedEvents.length, initialCount + 1, 'Signal should fire for different line');
378
});
379
380
test('Cooldown constant is 5 seconds', () => {
381
assert.strictEqual(TRIGGER_INLINE_EDIT_ON_SAME_LINE_COOLDOWN, 5000, 'Same line cooldown should be 5s');
382
});
383
});
384
385
// #endregion
386
387
// #region Document switch behavior
388
389
suite('Document switch behavior', () => {
390
test('Triggers on document switch when configured', () => {
391
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
392
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
393
394
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
395
nextEditProvider.lastOutcome = NesOutcome.Accepted;
396
397
// Configure to trigger on document switch
398
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
399
400
// Make a change in doc1
401
triggerTextChange(doc1.document);
402
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
403
404
const initialCount = firedEvents.length;
405
406
// Switch to doc2
407
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
408
409
assert.isAtLeast(firedEvents.length, initialCount + 1, 'Signal should fire on document switch');
410
assert.strictEqual(getLastFiredReason(), NesTriggerReason.ActiveDocumentSwitch);
411
});
412
413
test('Does not trigger on same document', () => {
414
const { document, textEditor } = createTextDocument();
415
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
416
417
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
418
419
triggerTextChange(document);
420
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
421
422
// Same document, just moving cursor (no tracked change for line 1)
423
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
424
425
// Should not trigger a document switch event for same document
426
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
427
assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch for same doc');
428
});
429
430
test('Does not trigger when document switch is disabled', () => {
431
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
432
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
433
434
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
435
436
// Don't configure document switch trigger (leave as undefined)
437
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, undefined);
438
439
triggerTextChange(doc1.document);
440
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
441
442
// Switch to doc2 without making changes there
443
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
444
445
// Should not trigger because doc2 has no tracked changes and switch trigger is disabled
446
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
447
assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch when disabled');
448
});
449
450
test('Does not trigger on document switch when there is no recent NES trigger (lastTriggerTime is 0)', () => {
451
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
452
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
453
454
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
455
nextEditProvider.lastTriggerTime = 0; // No previous trigger
456
457
// Configure to trigger on document switch
458
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
459
460
// Make a change in doc1
461
triggerTextChange(doc1.document);
462
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
463
464
const initialCount = firedEvents.length;
465
466
// Switch to doc2
467
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
468
469
// Should not trigger document switch because lastTriggerTime is 0
470
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
471
assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch when lastTriggerTime is 0');
472
assert.strictEqual(firedEvents.length, initialCount, 'No new events should fire');
473
});
474
475
test('Does not trigger on document switch when NES trigger was too long ago', () => {
476
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
477
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
478
479
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
480
481
const triggerAfterSeconds = 30;
482
// Configure to trigger on document switch
483
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, triggerAfterSeconds);
484
485
// Make a change in doc1
486
triggerTextChange(doc1.document);
487
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
488
489
const initialCount = firedEvents.length;
490
491
// Set lastTriggerTime to be older than the configured threshold
492
nextEditProvider.lastTriggerTime = Date.now() - (triggerAfterSeconds * 1000) - 1;
493
494
// Switch to doc2
495
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
496
497
// Should not trigger document switch because last trigger was too long ago
498
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
499
assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch when last trigger was too long ago');
500
assert.strictEqual(firedEvents.length, initialCount, 'No new events should fire');
501
});
502
503
test('Triggers on document switch when NES trigger was recent', () => {
504
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
505
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
506
507
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
508
nextEditProvider.lastOutcome = NesOutcome.Accepted;
509
510
const triggerAfterSeconds = 30;
511
// Configure to trigger on document switch
512
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, triggerAfterSeconds);
513
514
// Make a change in doc1
515
triggerTextChange(doc1.document);
516
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
517
518
const initialCount = firedEvents.length;
519
520
// Set lastTriggerTime to be within the configured threshold
521
nextEditProvider.lastTriggerTime = Date.now() - (triggerAfterSeconds * 1000) + 5000; // 5 seconds within the threshold
522
523
// Switch to doc2
524
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
525
526
// Should trigger document switch because last trigger was recent
527
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
528
assert.strictEqual(switchEvents.length, 1, 'Should trigger document switch when last trigger was recent');
529
assert.isAtLeast(firedEvents.length, initialCount + 1, 'Should have fired an additional event');
530
});
531
});
532
533
// #endregion
534
535
// #region Debounce behavior
536
537
suite('Debounce behavior', () => {
538
test('First two selection changes fire immediately when debounce is configured', () => {
539
const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3');
540
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
541
542
// Configure debounce
543
void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, 100);
544
545
triggerTextChange(document);
546
547
// First selection change - should fire immediately
548
triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));
549
assert.strictEqual(firedEvents.length, 1, 'First selection change should fire immediately');
550
551
// Second selection change - should also fire immediately
552
triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));
553
assert.strictEqual(firedEvents.length, 2, 'Second selection change should fire immediately');
554
});
555
556
test('Third and subsequent selection changes are debounced', async () => {
557
const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3\nline4\nline5');
558
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
559
560
const debounceMs = 50;
561
void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, debounceMs);
562
563
triggerTextChange(document);
564
565
// First two fire immediately
566
triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));
567
triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));
568
assert.strictEqual(firedEvents.length, 2, 'First two should fire immediately');
569
570
// Third selection change - should be debounced
571
triggerTextSelectionChange(textEditor, new Selection(2, 0, 2, 0));
572
assert.strictEqual(firedEvents.length, 2, 'Third should not fire immediately');
573
574
// Wait for debounce
575
await new Promise(resolve => setTimeout(resolve, debounceMs + 20));
576
assert.strictEqual(firedEvents.length, 3, 'Third should fire after debounce');
577
});
578
579
test('No debounce when config is undefined', () => {
580
const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3\nline4');
581
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
582
583
// No debounce config
584
void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, undefined);
585
586
triggerTextChange(document);
587
588
// All selection changes should fire immediately
589
triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));
590
triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));
591
triggerTextSelectionChange(textEditor, new Selection(2, 0, 2, 0));
592
triggerTextSelectionChange(textEditor, new Selection(3, 0, 3, 0));
593
594
assert.strictEqual(firedEvents.length, 4, 'All selection changes should fire immediately without debounce');
595
});
596
});
597
598
// #endregion
599
600
// #region Event data validation
601
602
suite('Event data validation', () => {
603
test('Fired event has valid NesChangeHint structure', () => {
604
const { document, textEditor } = createTextDocument();
605
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
606
607
triggerTextChange(document);
608
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
609
610
assert.isAtLeast(firedEvents.length, 1, 'Should have fired at least one event');
611
612
const event = firedEvents[0];
613
assert.isTrue(NesChangeHint.is(event), 'Event should be a valid NesChangeHint');
614
assert.isString(event.data.uuid, 'UUID should be a string');
615
assert.isNotEmpty(event.data.uuid, 'UUID should not be empty');
616
assert.strictEqual(event.data.reason, NesTriggerReason.SelectionChange);
617
});
618
619
test('Each trigger has a unique UUID', () => {
620
const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2');
621
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
622
623
triggerTextChange(document);
624
triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));
625
triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));
626
627
assert.isAtLeast(firedEvents.length, 2, 'Should have at least 2 events');
628
629
const uuids = firedEvents.map(e => e.data.uuid);
630
const uniqueUuids = new Set(uuids);
631
assert.strictEqual(uniqueUuids.size, uuids.length, 'All UUIDs should be unique');
632
});
633
});
634
635
// #endregion
636
637
// #region toRange returning undefined
638
639
suite('toRange returning undefined', () => {
640
test('No signal when toRange returns undefined', () => {
641
const uri = Uri.file('norange.py');
642
const doc = createTextDocumentData(uri, 'content', 'python');
643
const selection = new Selection(0, 0, 0, 0);
644
const textEditor = new ExtHostTextEditor(doc.document, [selection], {}, [], undefined);
645
646
// Register doc with a toRange that always returns undefined
647
const obsDoc: IVSCodeObservableDocument = {
648
id: DocumentId.create(uri.toString()),
649
toRange: () => undefined
650
} as any;
651
vscWorkspace.addDoc(doc.document, obsDoc);
652
653
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
654
655
triggerTextChange(doc.document);
656
triggerTextSelectionChange(textEditor.value, new Selection(0, 5, 0, 5));
657
658
assert.strictEqual(firedEvents.length, 0, 'Signal should not fire when toRange returns undefined');
659
});
660
});
661
662
// #endregion
663
664
// #region Notebook cell same-line cooldown bypass
665
666
suite('Notebook cell behavior', () => {
667
function createNotebookCellDocument(
668
cellId: string = '1',
669
content = 'print("hello")'
670
): { document: TextDocument; textEditor: TextEditor } {
671
const uri = Uri.parse(`vscode-notebook-cell://notebook/${cellId}`);
672
const doc = createTextDocumentData(uri, content, 'python');
673
const selection = new Selection(0, 0, 0, 0);
674
const textEditor = new ExtHostTextEditor(doc.document, [selection], {}, [], undefined);
675
vscWorkspace.addDoc(doc.document, createObservableTextDoc(doc.document.uri));
676
return { document: doc.document, textEditor: textEditor.value };
677
}
678
679
test('Notebook cell bypasses same-line cooldown when documentTrigger differs', () => {
680
// Create two notebook cells: edit one, then move in another
681
const cell1 = createNotebookCellDocument('cell1');
682
const cell2 = createNotebookCellDocument('cell2', 'x = 1');
683
684
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
685
686
// Ensure triggerOnActiveEditorChange is NOT set so the notebook-specific path is the only way to bypass
687
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, undefined);
688
689
// Edit cell1 (this registers `documentTrigger` as cell1.document)
690
triggerTextChange(cell1.document);
691
triggerTextSelectionChange(cell1.textEditor, new Selection(0, 0, 0, 0));
692
693
const countAfterFirst = firedEvents.length;
694
assert.isAtLeast(countAfterFirst, 1, 'First trigger should fire');
695
696
// Now manually set the internal tracking to point at cell2's docId, simulating that the
697
// user has moved to cell2 which is a notebook cell with a different document than documentTrigger.
698
// We trigger a text change on cell2 so it gets tracked, then selection on the same line.
699
triggerTextChange(cell2.document);
700
triggerTextSelectionChange(cell2.textEditor, new Selection(0, 0, 0, 0));
701
702
const countAfterSecond = firedEvents.length;
703
assert.isAtLeast(countAfterSecond, countAfterFirst + 1, 'Should trigger in cell2 on line 0');
704
705
// Move within cell2 on the SAME line — because cell2.document !== documentTrigger (cell2.document
706
// was set as documentTrigger by the previous trigger, so same-doc, same-line cooldown applies normally)
707
// But if we trigger another text change (like in cell1) then move to cell2 on same line,
708
// the notebook path bypasses the cooldown since e.textEditor.document !== mostRecentChange.documentTrigger
709
triggerTextChange(cell1.document);
710
// Now the tracking for cell1 is refreshed. Move selection in cell2:
711
// For this to test the notebook path, we need the mostRecentChange to be for the cell2 doc id
712
// but documentTrigger to differ from the current textEditor document.
713
// Actually, the code checks: isNotebookCell(uri) || doc === mostRecentChange.documentTrigger
714
// When isNotebookCell is true AND doc !== mostRecentChange.documentTrigger, cooldown is bypassed.
715
716
// Let's set up this scenario cleanly:
717
// 1. Edit cell2 — now tracking cell2 with documentTrigger = cell2.document
718
triggerTextChange(cell2.document);
719
triggerTextSelectionChange(cell2.textEditor, new Selection(0, 0, 0, 0));
720
const countBeforeBypass = firedEvents.length;
721
722
// 2. Now manually change the documentTrigger for the tracked entry of cell2
723
// by editing cell1 which shares the same docId tracking area — no, each doc has its own entry.
724
// Instead, the natural way notebook cells work: user edits cell1, then moves to cell2.
725
// But cell2 wouldn't have mostRecentChange unless it was edited.
726
// The key scenario: user edits cell2, triggers on line 0. Then edits cell1 (different cell).
727
// Now cell2 still has its LastChange with documentTrigger = cell2.document.
728
// User moves BACK to cell2 and the selection fires. Since no new edit on cell2,
729
// the existing LastChange is used. documentTrigger is cell2.document, and textEditor.document
730
// is also cell2.document — so they ARE equal, cooldown applies normally.
731
732
// The bypass scenario: triggerTextChange fires for cell2.document, creating LastChange with
733
// documentTrigger = cell2.document. Then another selection event comes for cell2 but from
734
// a DIFFERENT textEditor.document. This happens when VS Code reloads cell documents.
735
// We can simulate this by registering a new doc object for the same notebook cell URI.
736
const cell2Alt = createNotebookCellDocument('cell2', 'x = 1');
737
// cell2Alt.document is a NEW object but has same URI
738
// The docToLastChangeMap tracks by DocumentId (keyed on URI string), so the existing LastChange
739
// for cell2 is reused. Its documentTrigger is cell2.document, but now e.textEditor.document
740
// is cell2Alt.document — a different object => bypass cooldown.
741
742
triggerTextSelectionChange(cell2Alt.textEditor, new Selection(0, 0, 0, 0));
743
assert.isAtLeast(firedEvents.length, countBeforeBypass + 1,
744
'Should bypass same-line cooldown for notebook cell when documentTrigger differs');
745
});
746
747
test('Notebook cell respects same-line cooldown when documentTrigger matches', () => {
748
const cell = createNotebookCellDocument('cell1');
749
750
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
751
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, undefined);
752
753
triggerTextChange(cell.document);
754
triggerTextSelectionChange(cell.textEditor, new Selection(0, 0, 0, 0));
755
756
const countAfterFirst = firedEvents.length;
757
assert.isAtLeast(countAfterFirst, 1, 'First trigger should fire');
758
759
// Same line, same document object — cooldown should apply even for notebook cells
760
triggerTextSelectionChange(cell.textEditor, new Selection(0, 5, 0, 5));
761
assert.strictEqual(firedEvents.length, countAfterFirst,
762
'Should respect same-line cooldown when documentTrigger matches');
763
});
764
});
765
766
// #endregion
767
768
// #region Line trigger cleanup
769
770
suite('Line trigger cleanup', () => {
771
test('Stale line triggers are cleaned up when count exceeds 100', () => {
772
// Generate a document with >102 lines
773
const lines = Array.from({ length: 110 }, (_, i) => `line${i}`).join('\n');
774
const { document, textEditor } = createTextDocument(undefined, undefined, lines);
775
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
776
777
triggerTextChange(document);
778
779
// Trigger selection changes on 101 different lines to fill the map
780
for (let i = 0; i < 101; i++) {
781
triggerTextSelectionChange(textEditor, new Selection(i, 0, i, 0));
782
}
783
784
// All 101 triggers should have fired (each on a different line, no same-line cooldown)
785
assert.strictEqual(firedEvents.length, 101, 'All 101 triggers should fire');
786
787
// The next trigger (line 101) should still work — the cleanup runs but all entries are recent
788
// so none are actually removed, and the trigger still fires
789
triggerTextSelectionChange(textEditor, new Selection(101, 0, 101, 0));
790
assert.strictEqual(firedEvents.length, 102, 'Trigger should still work after cleanup runs');
791
});
792
});
793
794
// #endregion
795
796
// #region Debounce edge cases
797
798
suite('Debounce edge cases', () => {
799
test('New text change resets consecutive selection change counter', async () => {
800
const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2\nline3\nline4\nline5\nline6');
801
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
802
803
const debounceMs = 50;
804
void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, debounceMs);
805
806
// First change cycle
807
triggerTextChange(document);
808
triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0)); // immediate (1st)
809
triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0)); // immediate (2nd)
810
triggerTextSelectionChange(textEditor, new Selection(2, 0, 2, 0)); // debounced (3rd)
811
assert.strictEqual(firedEvents.length, 2, 'Third should be debounced');
812
813
// Wait for debounce to complete
814
await new Promise(resolve => setTimeout(resolve, debounceMs + 20));
815
assert.strictEqual(firedEvents.length, 3, 'Debounced event should fire');
816
817
// New text change resets the counter by creating a new LastChange
818
triggerTextChange(document);
819
triggerTextSelectionChange(textEditor, new Selection(3, 0, 3, 0)); // immediate again (1st of new cycle)
820
assert.strictEqual(firedEvents.length, 4, 'First selection after new change should fire immediately');
821
822
triggerTextSelectionChange(textEditor, new Selection(4, 0, 4, 0)); // immediate (2nd of new cycle)
823
assert.strictEqual(firedEvents.length, 5, 'Second selection after new change should fire immediately');
824
825
triggerTextSelectionChange(textEditor, new Selection(5, 0, 5, 0)); // debounced (3rd of new cycle)
826
assert.strictEqual(firedEvents.length, 5, 'Third selection after new change should be debounced again');
827
});
828
829
test('Later debounced event replaces earlier pending one', async () => {
830
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join('\n');
831
const { document, textEditor } = createTextDocument(undefined, undefined, lines);
832
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
833
834
const debounceMs = 80;
835
void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, debounceMs);
836
837
triggerTextChange(document);
838
839
// First two fire immediately
840
triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));
841
triggerTextSelectionChange(textEditor, new Selection(1, 0, 1, 0));
842
assert.strictEqual(firedEvents.length, 2);
843
844
// Third is debounced
845
triggerTextSelectionChange(textEditor, new Selection(2, 0, 2, 0));
846
assert.strictEqual(firedEvents.length, 2, 'Third should be debounced');
847
848
// Fourth replaces the third's pending timeout (MutableDisposable)
849
triggerTextSelectionChange(textEditor, new Selection(3, 0, 3, 0));
850
assert.strictEqual(firedEvents.length, 2, 'Fourth should also be debounced');
851
852
// Wait for debounce — only ONE additional event should fire (the latest one)
853
await new Promise(resolve => setTimeout(resolve, debounceMs + 30));
854
assert.strictEqual(firedEvents.length, 3, 'Only one debounced event should fire (the latest)');
855
});
856
});
857
858
// #endregion
859
860
// #region Document switch edge cases
861
862
suite('Document switch edge cases', () => {
863
test('Does not trigger on document switch to copilot-ignored destination', () => {
864
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
865
// doc2 is NOT added to vscWorkspace (copilot-ignored)
866
const uri2 = Uri.file('ignored.py');
867
const doc2Data = createTextDocumentData(uri2, 'ignored content', 'python');
868
const doc2Editor = new ExtHostTextEditor(doc2Data.document, [new Selection(0, 0, 0, 0)], {}, [], undefined);
869
870
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
871
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
872
873
triggerTextChange(doc1.document);
874
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
875
const initialCount = firedEvents.length;
876
877
// Switch to ignored doc2
878
triggerTextSelectionChange(doc2Editor.value, new Selection(0, 0, 0, 0));
879
880
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
881
assert.strictEqual(switchEvents.length, 0, 'Should not trigger switch for copilot-ignored destination');
882
assert.strictEqual(firedEvents.length, initialCount, 'No new events should fire');
883
});
884
885
test('Does not trigger on document switch when toRange returns undefined at destination', () => {
886
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
887
888
// doc2 has toRange that returns undefined
889
const uri2 = Uri.file('norange2.py');
890
const doc2Data = createTextDocumentData(uri2, 'content', 'python');
891
const doc2Editor = new ExtHostTextEditor(doc2Data.document, [new Selection(0, 0, 0, 0)], {}, [], undefined);
892
const obsDoc2: IVSCodeObservableDocument = {
893
id: DocumentId.create(uri2.toString()),
894
toRange: () => undefined
895
} as any;
896
vscWorkspace.addDoc(doc2Data.document, obsDoc2);
897
898
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
899
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
900
901
triggerTextChange(doc1.document);
902
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
903
const initialCount = firedEvents.length;
904
905
// Switch to doc2 where toRange returns undefined
906
triggerTextSelectionChange(doc2Editor.value, new Selection(0, 0, 0, 0));
907
908
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
909
assert.strictEqual(switchEvents.length, 0, 'Should not trigger switch when toRange returns undefined');
910
assert.strictEqual(firedEvents.length, initialCount, 'No new events should fire');
911
});
912
913
test('Does not trigger on document switch when no edit has ever happened', () => {
914
// Do NOT fire any text change — lastEditTimestamp stays undefined
915
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
916
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
917
918
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
919
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
920
921
// Select in doc1 first (no text change, so no tracked change)
922
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 0, 0, 0));
923
// Switch to doc2
924
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
925
926
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
927
assert.strictEqual(switchEvents.length, 0, 'Should not trigger switch when no edits ever happened');
928
});
929
930
test('Document switch adds doc to tracking map, enabling subsequent cursor moves to trigger', () => {
931
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
932
const doc2 = createTextDocument(undefined, Uri.file('file2.py'), 'line1\nline2');
933
934
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
935
nextEditProvider.lastOutcome = NesOutcome.Accepted;
936
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
937
938
// Edit doc1 and trigger
939
triggerTextChange(doc1.document);
940
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
941
942
// Switch to doc2 — this triggers ActiveDocumentSwitch AND inserts LastChange for doc2
943
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
944
assert.strictEqual(getLastFiredReason(), NesTriggerReason.ActiveDocumentSwitch);
945
const countAfterSwitch = firedEvents.length;
946
947
// Now move cursor in doc2 to a different line — should trigger SelectionChange
948
// because the document switch added doc2 to the tracking map
949
triggerTextSelectionChange(doc2.textEditor, new Selection(1, 0, 1, 0));
950
assert.isAtLeast(firedEvents.length, countAfterSwitch + 1,
951
'Cursor move in switched-to doc should trigger');
952
assert.strictEqual(getLastFiredReason(), NesTriggerReason.SelectionChange);
953
});
954
});
955
956
// #endregion
957
958
// #region Text change listener edge cases
959
960
// #region Document switch afterAcceptance strategy
961
962
suite('Document switch afterAcceptance strategy', () => {
963
964
function setupForDocSwitch() {
965
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
966
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
967
968
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
969
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
970
void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsTriggerOnEditorChangeStrategy, DocumentSwitchTriggerStrategy.AfterAcceptance);
971
972
// Edit doc1 and trigger to establish state
973
triggerTextChange(doc1.document);
974
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
975
976
return { doc1, doc2, eventsBeforeSwitch: firedEvents.length };
977
}
978
979
test('triggers on document switch when lastOutcome is Accepted', () => {
980
const { doc2, eventsBeforeSwitch } = setupForDocSwitch();
981
nextEditProvider.lastOutcome = NesOutcome.Accepted;
982
983
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
984
985
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
986
assert.strictEqual(switchEvents.length, 1, 'Should trigger document switch after acceptance');
987
assert.isAbove(firedEvents.length, eventsBeforeSwitch);
988
});
989
990
test('does not trigger on document switch when lastOutcome is Rejected', () => {
991
const { doc2, eventsBeforeSwitch } = setupForDocSwitch();
992
nextEditProvider.lastOutcome = NesOutcome.Rejected;
993
994
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
995
996
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
997
assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch after rejection');
998
assert.strictEqual(firedEvents.length, eventsBeforeSwitch);
999
});
1000
1001
test('does not trigger on document switch when lastOutcome is Ignored', () => {
1002
const { doc2, eventsBeforeSwitch } = setupForDocSwitch();
1003
nextEditProvider.lastOutcome = NesOutcome.Ignored;
1004
1005
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1006
1007
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
1008
assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch after ignore');
1009
assert.strictEqual(firedEvents.length, eventsBeforeSwitch);
1010
});
1011
1012
test('does not trigger on document switch when lastOutcome is undefined (pending)', () => {
1013
const { doc2, eventsBeforeSwitch } = setupForDocSwitch();
1014
nextEditProvider.lastOutcome = undefined;
1015
1016
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1017
1018
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
1019
assert.strictEqual(switchEvents.length, 0, 'Should not trigger document switch when outcome is pending');
1020
assert.strictEqual(firedEvents.length, eventsBeforeSwitch);
1021
});
1022
1023
test('triggers on document switch with default strategy regardless of lastOutcome', () => {
1024
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
1025
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
1026
1027
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
1028
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
1029
void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsTriggerOnEditorChangeStrategy, DocumentSwitchTriggerStrategy.Always);
1030
1031
nextEditProvider.lastOutcome = NesOutcome.Rejected;
1032
1033
triggerTextChange(doc1.document);
1034
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
1035
const eventsBeforeSwitch = firedEvents.length;
1036
1037
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1038
1039
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
1040
assert.strictEqual(switchEvents.length, 1, 'Default strategy should trigger on doc switch regardless of outcome');
1041
assert.isAbove(firedEvents.length, eventsBeforeSwitch);
1042
});
1043
1044
test('triggers on document switch with always strategy regardless of lastOutcome', () => {
1045
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
1046
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
1047
1048
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
1049
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
1050
void configurationService.setConfig(ConfigKey.TeamInternal.InlineEditsTriggerOnEditorChangeStrategy, DocumentSwitchTriggerStrategy.Always);
1051
1052
nextEditProvider.lastOutcome = NesOutcome.Ignored;
1053
1054
triggerTextChange(doc1.document);
1055
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
1056
const eventsBeforeSwitch = firedEvents.length;
1057
1058
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1059
1060
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
1061
assert.strictEqual(switchEvents.length, 1, 'Always strategy should trigger on doc switch regardless of outcome');
1062
assert.isAbove(firedEvents.length, eventsBeforeSwitch);
1063
});
1064
1065
suite('race condition: suggestion shown but not yet resolved', () => {
1066
test('previous NES was accepted, new suggestion shown (clears outcome), then doc switch — should NOT trigger', () => {
1067
// Scenario: user accepted an NES, a new suggestion is shown (handleShown
1068
// clears lastOutcome to undefined), then user switches documents before
1069
// the new suggestion is accepted/rejected/ignored.
1070
const { doc2, eventsBeforeSwitch } = setupForDocSwitch();
1071
1072
// Simulate: previous NES was accepted...
1073
nextEditProvider.lastOutcome = NesOutcome.Accepted;
1074
// ...then a new suggestion is shown, which clears lastOutcome
1075
nextEditProvider.lastOutcome = undefined;
1076
1077
// User switches documents while the new suggestion outcome is pending
1078
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1079
1080
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
1081
assert.strictEqual(switchEvents.length, 0,
1082
'Should not trigger: stale acceptance must not carry over when a new suggestion is pending');
1083
assert.strictEqual(firedEvents.length, eventsBeforeSwitch);
1084
});
1085
1086
test('NES shown, then accepted, then doc switch — should trigger', () => {
1087
// Scenario: suggestion shown → user accepts → user switches doc.
1088
// The acceptance callback has arrived, so lastOutcome is Accepted.
1089
const { doc2 } = setupForDocSwitch();
1090
1091
// Simulate: suggestion shown (clears outcome)...
1092
nextEditProvider.lastOutcome = undefined;
1093
// ...then accepted
1094
nextEditProvider.lastOutcome = NesOutcome.Accepted;
1095
1096
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1097
1098
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
1099
assert.strictEqual(switchEvents.length, 1, 'Should trigger after resolved acceptance');
1100
});
1101
1102
test('NES shown, then rejected, then doc switch — should NOT trigger', () => {
1103
// Scenario: suggestion shown → user rejects → user switches doc.
1104
const { doc2, eventsBeforeSwitch } = setupForDocSwitch();
1105
1106
nextEditProvider.lastOutcome = undefined;
1107
nextEditProvider.lastOutcome = NesOutcome.Rejected;
1108
1109
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1110
1111
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
1112
assert.strictEqual(switchEvents.length, 0, 'Should not trigger after resolved rejection');
1113
assert.strictEqual(firedEvents.length, eventsBeforeSwitch);
1114
});
1115
});
1116
});
1117
1118
// #endregion
1119
1120
suite('Text change listener edge cases', () => {
1121
test('Text change on copilot-ignored doc does not track but updates lastEditTimestamp', () => {
1122
// Create a doc that is NOT in vscWorkspace (copilot-ignored)
1123
const uri = Uri.file('ignored.py');
1124
const doc = createTextDocumentData(uri, 'content', 'python');
1125
// Do NOT call vscWorkspace.addDoc — simulates copilot-ignored
1126
1127
const trackedDoc = createTextDocument(undefined, Uri.file('tracked.py'));
1128
1129
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
1130
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
1131
1132
// Fire text change on ignored doc — lastEditTimestamp gets set
1133
triggerTextChange(doc.document);
1134
1135
// Now switch to tracked doc — document switch should work because lastEditTimestamp was set
1136
triggerTextSelectionChange(trackedDoc.textEditor, new Selection(0, 0, 0, 0));
1137
1138
// Need to actually switch docs (first establish doc1 as "last")
1139
const doc2 = createTextDocument(undefined, Uri.file('tracked2.py'));
1140
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1141
1142
// The point is that lastEditTimestamp was updated by the ignored doc's change
1143
// which allows document switch to work for other docs
1144
// (This is tested indirectly — the triggerTextChange on an ignored doc
1145
// still sets lastEditTimestamp, which is a global field)
1146
assert.isTrue(true, 'Test verifies that ignored doc change does not throw');
1147
});
1148
1149
test('Undo/redo still updates lastEditTimestamp (only skips tracking)', () => {
1150
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
1151
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
1152
1153
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
1154
nextEditProvider.lastOutcome = NesOutcome.Accepted;
1155
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
1156
1157
// Fire an undo change — this should still update lastEditTimestamp
1158
// even though it doesn't track the doc in docToLastChangeMap
1159
triggerTextChange(doc1.document, TextDocumentChangeReason.Undo);
1160
1161
// Select in doc1 to set lastDocWithSelectionUri.
1162
// Since lastDocWithSelectionUri starts undefined, this is also considered a "switch"
1163
// and fires an ActiveDocumentSwitch (because lastEditTimestamp was set by undo).
1164
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 0, 0, 0));
1165
1166
// Switch to doc2 — document switch should work because lastEditTimestamp was set by the undo
1167
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1168
1169
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
1170
assert.isAtLeast(switchEvents.length, 1,
1171
'Undo should still update lastEditTimestamp enabling document switch');
1172
});
1173
1174
test('Output pane text change does not update lastEditTimestamp', () => {
1175
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
1176
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
1177
const { document: outputDocument } = createOutputDocument();
1178
1179
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
1180
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
1181
1182
// Fire text change on output document — should be completely ignored
1183
triggerTextChange(outputDocument);
1184
1185
// Select in doc1 to establish lastDocWithSelectionUri
1186
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 0, 0, 0));
1187
1188
// Switch to doc2 — should NOT trigger because lastEditTimestamp was never set
1189
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1190
1191
const switchEvents = firedEvents.filter(e => e.data.reason === NesTriggerReason.ActiveDocumentSwitch);
1192
assert.strictEqual(switchEvents.length, 0,
1193
'Output doc change should not update lastEditTimestamp');
1194
});
1195
});
1196
1197
// #endregion
1198
1199
// #region Interaction edge cases
1200
1201
suite('Interaction edge cases', () => {
1202
test('Rejection cooldown prevents document switch triggers too', () => {
1203
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
1204
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
1205
1206
// Set rejection to be recent
1207
nextEditProvider.lastRejectionTime = Date.now();
1208
void configurationService.setConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, 30);
1209
1210
// Make a change in doc1
1211
triggerTextChange(doc1.document);
1212
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
1213
assert.strictEqual(firedEvents.length, 0, 'Should not fire during rejection cooldown');
1214
1215
// Switch to doc2 — rejection cooldown clears the tracked change,
1216
// so document switch's _maybeTriggerOnDocumentSwitch won't find a tracked entry
1217
// AND the rejection check happens before the switch check
1218
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1219
1220
assert.strictEqual(firedEvents.length, 0, 'Should not fire on doc switch during rejection cooldown');
1221
});
1222
1223
test('Same-line cooldown is bypassed after switching away and back', () => {
1224
const doc1 = createTextDocument(undefined, Uri.file('file1.py'));
1225
const doc2 = createTextDocument(undefined, Uri.file('file2.py'));
1226
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
1227
1228
// Edit doc1 and trigger on line 0
1229
triggerTextChange(doc1.document);
1230
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 5, 0, 5));
1231
1232
const initialCount = firedEvents.length;
1233
assert.isAtLeast(initialCount, 1, 'First trigger should fire');
1234
1235
// Same line — cooldown blocks
1236
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 10, 0, 10));
1237
assert.strictEqual(firedEvents.length, initialCount, 'Same-line cooldown should block');
1238
1239
// Switch to doc2
1240
triggerTextChange(doc2.document);
1241
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1242
const countAfterDoc2 = firedEvents.length;
1243
1244
// Switch back to doc1, same line — cooldown should be cleared by the doc switch
1245
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 10, 0, 10));
1246
assert.isAtLeast(firedEvents.length, countAfterDoc2 + 1,
1247
'Same-line cooldown should be bypassed after switching away and back');
1248
});
1249
1250
test('Output pane documents are ignored for selection changes', () => {
1251
const { textEditor, selection } = createOutputDocument();
1252
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
1253
1254
// Even without a text change, selection in output pane should be ignored
1255
triggerTextSelectionChange(textEditor, selection);
1256
assert.strictEqual(firedEvents.length, 0, 'Selection in output pane should be ignored');
1257
});
1258
1259
test('Copilot-ignored doc in selection listener returns early before rejection check', () => {
1260
// Create a doc not in the workspace
1261
const uri = Uri.file('not-in-workspace.py');
1262
const doc = createTextDocumentData(uri, 'content', 'python');
1263
const textEditor = new ExtHostTextEditor(doc.document, [new Selection(0, 0, 0, 0)], {}, [], undefined);
1264
// Do NOT add to vscWorkspace
1265
1266
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
1267
1268
triggerTextChange(doc.document); // won't track since not in workspace
1269
triggerTextSelectionChange(textEditor.value, new Selection(0, 5, 0, 5));
1270
1271
assert.strictEqual(firedEvents.length, 0,
1272
'Copilot-ignored doc should return early in selection listener');
1273
});
1274
1275
test('Multiple documents can independently track and trigger', () => {
1276
const doc1 = createTextDocument(undefined, Uri.file('file1.py'), 'line1\nline2');
1277
const doc2 = createTextDocument(undefined, Uri.file('file2.py'), 'line1\nline2');
1278
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
1279
1280
// Edit and trigger in doc1
1281
triggerTextChange(doc1.document);
1282
triggerTextSelectionChange(doc1.textEditor, new Selection(0, 0, 0, 0));
1283
assert.strictEqual(firedEvents.length, 1, 'Doc1 first trigger');
1284
1285
// Edit and trigger in doc2
1286
triggerTextChange(doc2.document);
1287
triggerTextSelectionChange(doc2.textEditor, new Selection(0, 0, 0, 0));
1288
assert.strictEqual(firedEvents.length, 2, 'Doc2 first trigger');
1289
1290
// Move in doc1 to a different line — should still work independently
1291
triggerTextSelectionChange(doc1.textEditor, new Selection(1, 0, 1, 0));
1292
assert.strictEqual(firedEvents.length, 3, 'Doc1 second trigger on different line');
1293
1294
// Move in doc2 to a different line
1295
triggerTextSelectionChange(doc2.textEditor, new Selection(1, 0, 1, 0));
1296
assert.strictEqual(firedEvents.length, 4, 'Doc2 second trigger on different line');
1297
});
1298
1299
test('Text change resets line triggers for the document', () => {
1300
const { document, textEditor } = createTextDocument(undefined, undefined, 'line1\nline2');
1301
nextEditProvider.lastRejectionTime = Date.now() - TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN - 1;
1302
1303
// Trigger on line 0
1304
triggerTextChange(document);
1305
triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));
1306
const count1 = firedEvents.length;
1307
assert.isAtLeast(count1, 1);
1308
1309
// Same line — should be in cooldown, no trigger
1310
triggerTextSelectionChange(textEditor, new Selection(0, 5, 0, 5));
1311
assert.strictEqual(firedEvents.length, count1, 'Same line should be in cooldown');
1312
1313
// New text change resets line triggers (creates a new LastChange)
1314
triggerTextChange(document);
1315
triggerTextSelectionChange(textEditor, new Selection(0, 0, 0, 0));
1316
assert.isAtLeast(firedEvents.length, count1 + 1,
1317
'After text change, same line should trigger again');
1318
});
1319
});
1320
1321
// #endregion
1322
});
1323
1324