Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineEditTriggerer.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type * as vscode from 'vscode';6import { TextDocumentChangeReason } from 'vscode';7import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';8import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId';9import { DocumentSwitchTriggerStrategy } from '../../../platform/inlineEdits/common/dataTypes/triggerOptions';10import { ILogger, ILogService } from '../../../platform/log/common/logService';11import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';12import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';13import { isNotebookCell } from '../../../util/common/notebooks';14import { Emitter } from '../../../util/vs/base/common/event';15import { Disposable, DisposableMap, IDisposable, MutableDisposable } from '../../../util/vs/base/common/lifecycle';16import { generateUuid } from '../../../util/vs/base/common/uuid';17import { createTimeout } from '../common/common';18import { NesChangeHint, NesTriggerReason } from '../common/nesTriggerHint';19import { NesOutcome, NextEditProvider } from '../node/nextEditProvider';20import { VSCodeWorkspace } from './parts/vscodeWorkspace';2122export const TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT = 10000; // 10 seconds23export const TRIGGER_INLINE_EDIT_ON_SAME_LINE_COOLDOWN = 5000; // milliseconds24export const TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN = 5000; // 5s2526class LastChange extends Disposable {27public lastEditedTimestamp: number;28public lineNumberTriggers: Map<number /* lineNumber */, number /* timestamp */>;2930public readonly timeout = this._register(new MutableDisposable<IDisposable>());3132private _nConsecutiveSelectionChanges = 0;33public get nConsecutiveSelectionChanges(): number {34return this._nConsecutiveSelectionChanges;35}36public incrementSelectionChangeEventCount(): void {37this._nConsecutiveSelectionChanges++;38}3940constructor(public documentTrigger: vscode.TextDocument) {41super();42this.lastEditedTimestamp = Date.now();43this.lineNumberTriggers = new Map();44}45}4647export class InlineEditTriggerer extends Disposable {4849private _onChangeEmitter = this._register(new Emitter<NesChangeHint>());50public readonly onChange = this._onChangeEmitter.event;5152private readonly docToLastChangeMap = this._register(new DisposableMap<DocumentId, LastChange>());5354private lastDocWithSelectionUri: string | undefined;5556/**57* Timestamp of the last edit in any document.58*/59private lastEditTimestamp: number | undefined;6061private readonly _logger: ILogger;6263constructor(64private readonly workspace: VSCodeWorkspace,65private readonly nextEditProvider: NextEditProvider,66@ILogService private readonly _logService: ILogService,67@IConfigurationService private readonly _configurationService: IConfigurationService,68@IExperimentationService private readonly _expService: IExperimentationService,69@IWorkspaceService private readonly _workspaceService: IWorkspaceService70) {71super();7273this._logger = this._logService.createSubLogger(['NES', 'Triggerer']);7475this.registerListeners();76}7778private registerListeners() {79this._registerDocumentChangeListener();80this._registerSelectionChangeListener();81}8283private _shouldIgnoreDoc(doc: vscode.TextDocument): boolean {84return doc.uri.scheme === 'output'; // ignore output pane documents85}8687private _registerDocumentChangeListener() {88this._register(this._workspaceService.onDidChangeTextDocument(e => {89if (this._shouldIgnoreDoc(e.document)) {90return;91}9293this.lastEditTimestamp = Date.now();9495const logger = this._logger.createSubLogger('onDidChangeTextDocument');9697if (e.reason === TextDocumentChangeReason.Undo || e.reason === TextDocumentChangeReason.Redo) { // ignore98logger.trace('Return: undo/redo');99return;100}101102const doc = this.workspace.getDocumentByTextDocument(e.document);103104if (!doc) { // doc is likely copilot-ignored105logger.trace('Return: ignored document');106return;107}108109this.docToLastChangeMap.set(doc.id, new LastChange(e.document));110111logger.trace(`Return: updated last edit timestamp and cleared line triggers for document for ${doc.id.uri}`);112}));113}114115private _registerSelectionChangeListener() {116this._register(this._workspaceService.onDidChangeTextEditorSelection(e => this._handleSelectionChange(e)));117}118119private _handleSelectionChange(e: vscode.TextEditorSelectionChangeEvent) {120if (this._shouldIgnoreDoc(e.textEditor.document)) {121return;122}123124const isSameDoc = this.lastDocWithSelectionUri === e.textEditor.document.uri.toString();125this.lastDocWithSelectionUri = e.textEditor.document.uri.toString();126127const logger = this._logger.createSubLogger('onDidChangeTextEditorSelection');128129if (e.selections.length !== 1) { // ignore multi-selection case130logger.trace('Return: multiple selections');131return;132}133134if (!e.selections[0].isEmpty) { // ignore non-empty selection135logger.trace('Return: not empty selection');136return;137}138139const doc = this.workspace.getDocumentByTextDocument(e.textEditor.document);140if (!doc) { // doc is likely copilot-ignored141return;142}143144if (this._isWithinRejectionCooldown()) {145// the cursor has moved within 5s of the last rejection, don't auto-trigger until another doc modification146this.docToLastChangeMap.deleteAndDispose(doc.id);147logger.trace('Return: rejection cooldown');148return;149}150151const mostRecentChange = this.docToLastChangeMap.get(doc.id);152if (!mostRecentChange) {153if (!this._maybeTriggerOnDocumentSwitch(e, isSameDoc, logger)) {154logger.trace('Return: document not tracked - does not have recent changes');155}156return;157}158159// When the user switches to a different file and comes back, clear same-line160// cooldowns so the triggerer fires again on the same line.161if (!isSameDoc) {162mostRecentChange.lineNumberTriggers.clear();163}164165const hadRecentEdit = this._hasRecentEdit(mostRecentChange);166if (!hadRecentEdit || !this._hasRecentTrigger()) {167// The edit is too old or the provider was not triggered recently (we might be168// observing a cursor change following an external edit) — try document switch.169const reason = hadRecentEdit ? 'no recent trigger' : 'no recent edit';170if (!this._maybeTriggerOnDocumentSwitch(e, isSameDoc, logger)) {171logger.trace(`Return: ${reason}`);172}173return;174}175176this._handleTrackedDocSelectionChange(e, doc, mostRecentChange, logger);177}178179/**180* Handles a selection change in a document that has a recent tracked edit and a recent NES trigger.181* Checks same-line cooldown, cleans up stale triggers, and fires the trigger (possibly debounced).182*/183private _handleTrackedDocSelectionChange(184e: vscode.TextEditorSelectionChangeEvent,185doc: NonNullable<ReturnType<VSCodeWorkspace['getDocumentByTextDocument']>>,186mostRecentChange: LastChange,187logger: ILogger,188) {189const range = doc.toRange(e.textEditor.document, e.selections[0]);190if (!range) {191logger.trace('Return: no range');192return;193}194195const selectionLine = range.start.line;196197if (this._isSameLineCooldownActive(mostRecentChange, selectionLine, e.textEditor.document)) {198logger.trace('Return: same line cooldown');199return;200}201202// TODO: Do not trigger if there is an existing valid request now running, ie don't use just last-trigger timestamp203this._cleanupStaleLineTriggers(mostRecentChange);204205mostRecentChange.lineNumberTriggers.set(selectionLine, Date.now());206mostRecentChange.documentTrigger = e.textEditor.document;207logger.trace('Return: triggering inline edit');208209this._triggerWithDebounce(mostRecentChange);210}211212// #region Helper predicates213214private _isWithinRejectionCooldown(): boolean {215return (Date.now() - this.nextEditProvider.lastRejectionTime) < TRIGGER_INLINE_EDIT_REJECTION_COOLDOWN;216}217218private _hasRecentEdit(mostRecentChange: LastChange): boolean {219return (Date.now() - mostRecentChange.lastEditedTimestamp) < TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT;220}221222private _hasRecentTrigger(): boolean {223return (Date.now() - this.nextEditProvider.lastTriggerTime) < TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT;224}225226/**227* Returns true if the same-line cooldown is active and we should skip triggering.228*229* The cooldown is bypassed when we're in a notebook cell and the current document230* differs from the one that originally triggered the change (user moved to a231* different cell).232*233* When the user switches to a different file and comes back, line triggers are234* cleared (see {@link _handleSelectionChange}), so the cooldown naturally resets.235*/236private _isSameLineCooldownActive(mostRecentChange: LastChange, selectionLine: number, currentDocument: vscode.TextDocument): boolean {237// In a notebook, if the user moved to a different cell, bypass the cooldown238if (isNotebookCell(currentDocument.uri) && currentDocument !== mostRecentChange.documentTrigger) {239return false; // cooldown bypassed240}241242const lastTriggerTimestampForLine = mostRecentChange.lineNumberTriggers.get(selectionLine);243return lastTriggerTimestampForLine !== undefined244&& (Date.now() - lastTriggerTimestampForLine) < TRIGGER_INLINE_EDIT_ON_SAME_LINE_COOLDOWN;245}246247// #endregion248249// #region Trigger helpers250251/**252* Removes line triggers older than {@link TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT}253* when the map grows beyond 100 entries.254*/255private _cleanupStaleLineTriggers(mostRecentChange: LastChange): void {256if (mostRecentChange.lineNumberTriggers.size <= 100) {257return;258}259const now = Date.now();260for (const [lineNumber, timestamp] of mostRecentChange.lineNumberTriggers.entries()) {261if (now - timestamp > TRIGGER_INLINE_EDIT_AFTER_CHANGE_LIMIT) {262mostRecentChange.lineNumberTriggers.delete(lineNumber);263}264}265}266267/**268* Fires a selection-change trigger, applying debounce when configured.269*270* The first 2 selection changes after an edit fire immediately (the 1st is caused by271* the edit itself, the 2nd is the user intentionally moving to the next edit location).272* Subsequent changes are debounced to avoid excessive triggering during rapid navigation.273*/274private _triggerWithDebounce(mostRecentChange: LastChange): void {275const debounceMs = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsDebounceOnSelectionChange, this._expService);276if (debounceMs === undefined) {277this._triggerInlineEdit(NesTriggerReason.SelectionChange);278return;279}280281const N_ALLOWED_IMMEDIATE_SELECTION_CHANGE_EVENTS = 2;282if (mostRecentChange.nConsecutiveSelectionChanges < N_ALLOWED_IMMEDIATE_SELECTION_CHANGE_EVENTS) {283this._triggerInlineEdit(NesTriggerReason.SelectionChange);284} else {285mostRecentChange.timeout.value = createTimeout(debounceMs, () => this._triggerInlineEdit(NesTriggerReason.SelectionChange));286}287mostRecentChange.incrementSelectionChangeEventCount();288}289290// #endregion291292private _maybeTriggerOnDocumentSwitch(e: vscode.TextEditorSelectionChangeEvent, isSameDoc: boolean, parentLogger: ILogger): boolean {293const logger = parentLogger.createSubLogger('editorSwitch');294const triggerAfterSeconds = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.InlineEditsTriggerOnEditorChangeAfterSeconds, this._expService);295if (triggerAfterSeconds === undefined) {296logger.trace('document switch disabled');297return false;298}299if (isSameDoc) {300logger.trace(`Return: document switch didn't happen`);301return false;302}303if (this.lastEditTimestamp === undefined) {304logger.trace('Return: no last edit timestamp');305return false;306}307const now = Date.now();308const triggerThresholdMs = triggerAfterSeconds * 1000;309const timeSinceLastEdit = now - this.lastEditTimestamp;310if (timeSinceLastEdit > triggerThresholdMs) {311logger.trace('Return: too long since last edit');312return false;313}314315// Require a recent NES trigger before triggering on document switch.316// lastTriggerTime === 0 means NES was never triggered in this session.317const timeSinceLastTrigger = now - this.nextEditProvider.lastTriggerTime;318if (this.nextEditProvider.lastTriggerTime === 0 || timeSinceLastTrigger > triggerThresholdMs) {319logger.trace('Return: no recent NES trigger');320return false;321}322323const strategy = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsTriggerOnEditorChangeStrategy, this._expService);324if (strategy === DocumentSwitchTriggerStrategy.AfterAcceptance && this.nextEditProvider.lastOutcome !== NesOutcome.Accepted) {325// When the afterAcceptance strategy is active, only trigger on document switch326// if the most recent NES was accepted. A pending outcome (undefined) is treated327// as not-accepted to avoid racing with the UI's accept/reject/ignore callback.328logger.trace('Return: afterAcceptance strategy requires last NES to be accepted');329return false;330}331332const doc = this.workspace.getDocumentByTextDocument(e.textEditor.document);333if (!doc) { // doc is likely copilot-ignored334logger.trace('Return: ignored document');335return false;336}337338const range = doc.toRange(e.textEditor.document, e.selections[0]);339if (!range) {340logger.trace('Return: no range');341return false;342}343344const selectionLine = range.start.line;345346// 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 back347const lastChange = new LastChange(e.textEditor.document);348lastChange.lineNumberTriggers.set(selectionLine, Date.now());349this.docToLastChangeMap.set(doc.id, lastChange);350351this._triggerInlineEdit(NesTriggerReason.ActiveDocumentSwitch);352return true;353}354355private _triggerInlineEdit(reason: NesTriggerReason) {356const uuid = generateUuid();357this._logger.trace(`Triggering inline edit: ${reason}`);358this._onChangeEmitter.fire({ data: { uuid, reason } });359}360}361362363