Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts
5328 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 { alert } from '../../../../../base/browser/ui/aria/aria.js';
7
import { timeout } from '../../../../../base/common/async.js';
8
import { cancelOnDispose } from '../../../../../base/common/cancellation.js';
9
import { createHotClass } from '../../../../../base/common/hotReloadHelpers.js';
10
import { Disposable, toDisposable } from '../../../../../base/common/lifecycle.js';
11
import { ITransaction, autorun, derived, derivedDisposable, derivedObservableWithCache, observableFromEvent, observableSignal, observableValue, runOnChange, runOnChangeWithStore, transaction, waitForState } from '../../../../../base/common/observable.js';
12
import { isEqual } from '../../../../../base/common/resources.js';
13
import { isUndefined } from '../../../../../base/common/types.js';
14
import { localize } from '../../../../../nls.js';
15
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
16
import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
17
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
18
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
19
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
20
import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';
21
import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js';
22
import { hotClassGetOriginalInstance } from '../../../../../platform/observable/common/wrapInHotClass.js';
23
import { CoreEditingCommands } from '../../../../browser/coreCommands.js';
24
import { ICodeEditor } from '../../../../browser/editorBrowser.js';
25
import { observableCodeEditor } from '../../../../browser/observableCodeEditor.js';
26
import { TriggerInlineEditCommandsRegistry } from '../../../../browser/triggerInlineEditCommandsRegistry.js';
27
import { getOuterEditor } from '../../../../browser/widget/codeEditor/embeddedCodeEditorWidget.js';
28
import { EditorOption } from '../../../../common/config/editorOptions.js';
29
import { Position } from '../../../../common/core/position.js';
30
import { Range } from '../../../../common/core/range.js';
31
import { CursorChangeReason } from '../../../../common/cursorEvents.js';
32
import { ILanguageFeatureDebounceService } from '../../../../common/services/languageFeatureDebounce.js';
33
import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js';
34
import { FIND_IDS } from '../../../find/browser/findModel.js';
35
import { NextMarkerAction, NextMarkerInFilesAction, PrevMarkerAction, PrevMarkerInFilesAction } from '../../../gotoError/browser/gotoError.js';
36
import { InsertLineAfterAction, InsertLineBeforeAction } from '../../../linesOperations/browser/linesOperations.js';
37
import { InlineSuggestionHintsContentWidget } from '../hintsWidget/inlineCompletionsHintsWidget.js';
38
import { TextModelChangeRecorder } from '../model/changeRecorder.js';
39
import { InlineCompletionsModel } from '../model/inlineCompletionsModel.js';
40
import { ObservableSuggestWidgetAdapter } from '../model/suggestWidgetAdapter.js';
41
import { ObservableContextKeyService } from '../utils.js';
42
import { InlineSuggestionsView } from '../view/inlineSuggestionsView.js';
43
import { inlineSuggestCommitId } from './commandIds.js';
44
import { setInlineCompletionsControllerGetter } from './common.js';
45
import { InlineCompletionContextKeys } from './inlineCompletionContextKeys.js';
46
47
setInlineCompletionsControllerGetter((editor) => InlineCompletionsController.get(editor));
48
49
export class InlineCompletionsController extends Disposable {
50
private static readonly _instances = new Set<InlineCompletionsController>();
51
52
public static hot = createHotClass(this);
53
public static ID = 'editor.contrib.inlineCompletionsController';
54
55
/**
56
* Find the controller in the focused editor or in the outer editor (if applicable)
57
*/
58
public static getInFocusedEditorOrParent(accessor: ServicesAccessor): InlineCompletionsController | null {
59
const outerEditor = getOuterEditor(accessor);
60
if (!outerEditor) {
61
return null;
62
}
63
return InlineCompletionsController.get(outerEditor);
64
}
65
66
public static get(editor: ICodeEditor): InlineCompletionsController | null {
67
return hotClassGetOriginalInstance(editor.getContribution<InlineCompletionsController>(InlineCompletionsController.ID));
68
}
69
70
private readonly _editorObs;
71
private readonly _positions;
72
73
private readonly _suggestWidgetAdapter;
74
75
private readonly _enabledInConfig;
76
private readonly _isScreenReaderEnabled;
77
private readonly _editorDictationInProgress;
78
private readonly _enabled = derived(this, reader => this._enabledInConfig.read(reader) && (!this._isScreenReaderEnabled.read(reader) || !this._editorDictationInProgress.read(reader)));
79
80
private readonly _debounceValue;
81
82
private readonly _focusIsInMenu = observableValue<boolean>(this, false);
83
private readonly _focusIsInEditorOrMenu = derived(this, reader => {
84
const editorHasFocus = this._editorObs.isFocused.read(reader);
85
const menuHasFocus = this._focusIsInMenu.read(reader);
86
return editorHasFocus || menuHasFocus;
87
});
88
89
private readonly _cursorIsInIndentation = derived(this, reader => {
90
const cursorPos = this._editorObs.cursorPosition.read(reader);
91
if (cursorPos === null) { return false; }
92
const model = this._editorObs.model.read(reader);
93
if (!model) { return false; }
94
this._editorObs.versionId.read(reader);
95
const indentMaxColumn = model.getLineIndentColumn(cursorPos.lineNumber);
96
return cursorPos.column <= indentMaxColumn;
97
});
98
99
public readonly model = derivedDisposable<InlineCompletionsModel | undefined>(this, reader => {
100
if (this._editorObs.isReadonly.read(reader)) { return undefined; }
101
const textModel = this._editorObs.model.read(reader);
102
if (!textModel) { return undefined; }
103
104
const model: InlineCompletionsModel = this._instantiationService.createInstance(
105
InlineCompletionsModel,
106
textModel,
107
this._suggestWidgetAdapter.selectedItem,
108
this._editorObs.versionId,
109
this._positions,
110
this._debounceValue,
111
this._enabled,
112
this.editor,
113
);
114
return model;
115
});
116
117
private readonly _playAccessibilitySignal = observableSignal(this);
118
119
private readonly _hideInlineEditOnSelectionChange;
120
121
protected readonly _view = derived(reader => reader.store.add(this._instantiationService.createInstance(InlineSuggestionsView.hot.read(reader), this.editor, this.model, this._focusIsInMenu)));
122
123
constructor(
124
public readonly editor: ICodeEditor,
125
@IInstantiationService private readonly _instantiationService: IInstantiationService,
126
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
127
@IConfigurationService private readonly _configurationService: IConfigurationService,
128
@ICommandService private readonly _commandService: ICommandService,
129
@ILanguageFeatureDebounceService private readonly _debounceService: ILanguageFeatureDebounceService,
130
@ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService,
131
@IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService,
132
@IKeybindingService private readonly _keybindingService: IKeybindingService,
133
@IAccessibilityService private readonly _accessibilityService: IAccessibilityService
134
) {
135
super();
136
this._editorObs = observableCodeEditor(this.editor);
137
this._positions = derived(this, reader => this._editorObs.selections.read(reader)?.map(s => s.getEndPosition()) ?? [new Position(1, 1)]);
138
this._suggestWidgetAdapter = this._register(new ObservableSuggestWidgetAdapter(
139
this._editorObs,
140
item => this.model.get()?.handleSuggestAccepted(item),
141
() => this.model.get()?.selectedInlineCompletion.get()?.getSingleTextEdit(),
142
));
143
this._enabledInConfig = observableFromEvent(this, this.editor.onDidChangeConfiguration, () => this.editor.getOption(EditorOption.inlineSuggest).enabled);
144
this._isScreenReaderEnabled = observableFromEvent(this, this._accessibilityService.onDidChangeScreenReaderOptimized, () => this._accessibilityService.isScreenReaderOptimized());
145
this._editorDictationInProgress = observableFromEvent(this,
146
this._contextKeyService.onDidChangeContext,
147
() => this._contextKeyService.getContext(this.editor.getDomNode()).getValue('editorDictation.inProgress') === true
148
);
149
150
this._debounceValue = this._debounceService.for(
151
this._languageFeaturesService.inlineCompletionsProvider,
152
'InlineCompletionsDebounce',
153
{ min: 50, max: 50 }
154
);
155
this.model.recomputeInitiallyAndOnChange(this._store);
156
this._hideInlineEditOnSelectionChange = this._editorObs.getOption(EditorOption.inlineSuggest).map(val => true);
157
158
this._view.recomputeInitiallyAndOnChange(this._store);
159
160
InlineCompletionsController._instances.add(this);
161
this._register(toDisposable(() => InlineCompletionsController._instances.delete(this)));
162
163
this._register(autorun(reader => {
164
// Cancel all other inline completions when a new one starts
165
const model = this.model.read(reader);
166
if (!model) { return; }
167
const state = model.state.read(reader);
168
if (!state) { return; }
169
if (!this._focusIsInEditorOrMenu.read(undefined)) { return; }
170
171
// This controller is in focus, hence reject others.
172
// However if we display a NES that relates to another edit then trigger NES on that related controller
173
const nextEditUri = state.kind === 'inlineEdit' ? state.nextEditUri : undefined;
174
for (const ctrl of InlineCompletionsController._instances) {
175
if (ctrl === this) {
176
continue;
177
} else if (nextEditUri && isEqual(nextEditUri, ctrl.editor.getModel()?.uri)) {
178
// The next edit in other edito is related to this controller, trigger it.
179
ctrl.model.read(undefined)?.trigger();
180
} else {
181
ctrl.reject();
182
}
183
}
184
}));
185
this._register(autorun(reader => {
186
// Cancel all other inline completions when a new one starts
187
const model = this.model.read(reader);
188
const uri = this.editor.getModel()?.uri;
189
if (!model || !uri) { return; }
190
191
// This NES was accepted, its possible there is an NES that points to this editor.
192
// I.e. there's an NES that reads `Go To Next Edit`,
193
// If there is one that points to this editor, then we need to hide that as this NES was accepted.
194
reader.store.add(model.onDidAccept(() => {
195
for (const ctrl of InlineCompletionsController._instances) {
196
if (ctrl === this) {
197
continue;
198
}
199
// Find the nes from another editor that points to this.
200
const state = ctrl.model.read(undefined)?.state.read(undefined);
201
if (state?.kind === 'inlineEdit' && isEqual(state.nextEditUri, uri)) {
202
ctrl.model.read(undefined)?.stop('automatic');
203
}
204
}
205
}));
206
207
}));
208
209
this._register(runOnChange(this._editorObs.onDidType, (_value, _changes) => {
210
if (this._enabled.get()) {
211
this.model.get()?.trigger();
212
}
213
}));
214
215
this._register(runOnChange(this._editorObs.onDidPaste, (_value, _changes) => {
216
if (this._enabled.get()) {
217
this.model.get()?.trigger();
218
}
219
}));
220
221
// These commands don't trigger onDidType.
222
const triggerCommands = new Set([
223
CoreEditingCommands.Tab.id,
224
CoreEditingCommands.DeleteLeft.id,
225
CoreEditingCommands.DeleteRight.id,
226
inlineSuggestCommitId,
227
'acceptSelectedSuggestion',
228
InsertLineAfterAction.ID,
229
InsertLineBeforeAction.ID,
230
FIND_IDS.NextMatchFindAction,
231
NextMarkerAction.ID,
232
PrevMarkerAction.ID,
233
NextMarkerInFilesAction.ID,
234
PrevMarkerInFilesAction.ID,
235
...TriggerInlineEditCommandsRegistry.getRegisteredCommands(),
236
]);
237
this._register(this._commandService.onDidExecuteCommand((e) => {
238
if (triggerCommands.has(e.commandId) && editor.hasTextFocus() && this._enabled.get()) {
239
let noDelay = false;
240
if (e.commandId === inlineSuggestCommitId) {
241
noDelay = true;
242
}
243
this._editorObs.forceUpdate(tx => {
244
/** @description onDidExecuteCommand */
245
this.model.get()?.trigger(tx, { noDelay });
246
});
247
}
248
}));
249
250
this._register(runOnChange(this._editorObs.selections, (_value, _, changes) => {
251
if (changes.some(e => e.reason === CursorChangeReason.Explicit || e.source === 'api')) {
252
if (!this._hideInlineEditOnSelectionChange.get() && this.model.get()?.state.get()?.kind === 'inlineEdit') {
253
return;
254
}
255
const m = this.model.get();
256
if (!m) { return; }
257
if (m.state.get()?.kind === 'ghostText') {
258
this.model.get()?.stop();
259
}
260
}
261
}));
262
263
this._register(autorun(reader => {
264
const isFocused = this._focusIsInEditorOrMenu.read(reader);
265
const model = this.model.read(undefined);
266
if (isFocused) {
267
// If this model already has an NES for another editor, then leave as is
268
// Else stop other models.
269
const state = model?.state.read(undefined);
270
if (!state || state.kind !== 'inlineEdit' || !state.nextEditUri) {
271
transaction(tx => {
272
for (const ctrl of InlineCompletionsController._instances) {
273
if (ctrl !== this) {
274
ctrl.model.read(undefined)?.stop('automatic', tx);
275
}
276
}
277
});
278
}
279
return;
280
}
281
282
// This is a hidden setting very useful for debugging
283
if (this._contextKeyService.getContextKeyValue<boolean>('accessibleViewIsShown')
284
|| this._configurationService.getValue('editor.inlineSuggest.keepOnBlur')
285
|| editor.getOption(EditorOption.inlineSuggest).keepOnBlur
286
|| InlineSuggestionHintsContentWidget.dropDownVisible) {
287
return;
288
}
289
290
if (!model) { return; }
291
if (model.state.read(undefined)?.inlineSuggestion?.isFromExplicitRequest && model.inlineEditAvailable.read(undefined)) {
292
// dont hide inline edits on blur when requested explicitly
293
return;
294
}
295
296
transaction(tx => {
297
/** @description InlineCompletionsController.onDidBlurEditorWidget */
298
model.stop('automatic', tx);
299
});
300
}));
301
302
this._register(autorun(reader => {
303
/** @description InlineCompletionsController.forceRenderingAbove */
304
const state = this.model.read(reader)?.inlineCompletionState.read(reader);
305
if (state?.suggestItem) {
306
if (state.primaryGhostText.lineCount >= 2) {
307
this._suggestWidgetAdapter.forceRenderingAbove();
308
}
309
} else {
310
this._suggestWidgetAdapter.stopForceRenderingAbove();
311
}
312
}));
313
this._register(toDisposable(() => {
314
this._suggestWidgetAdapter.stopForceRenderingAbove();
315
}));
316
317
const currentInlineCompletionBySemanticId = derivedObservableWithCache<string | undefined>(this, (reader, last) => {
318
const model = this.model.read(reader);
319
const state = model?.state.read(reader);
320
if (this._suggestWidgetAdapter.selectedItem.get()) {
321
return last;
322
}
323
return state?.inlineSuggestion?.semanticId;
324
});
325
this._register(runOnChangeWithStore(derived(reader => {
326
this._playAccessibilitySignal.read(reader);
327
currentInlineCompletionBySemanticId.read(reader);
328
return {};
329
}), async (_value, _, _deltas, store) => {
330
/** @description InlineCompletionsController.playAccessibilitySignalAndReadSuggestion */
331
let model = this.model.get();
332
let state = model?.state.get();
333
if (!state || !model) { return; }
334
335
await timeout(50, cancelOnDispose(store));
336
await waitForState(this._suggestWidgetAdapter.selectedItem, isUndefined, () => false, cancelOnDispose(store));
337
338
model = this.model.get();
339
state = model?.state.get();
340
if (!state || !model) { return; }
341
const lineText = state.kind === 'ghostText' ? model.textModel.getLineContent(state.primaryGhostText.lineNumber) : '';
342
this._accessibilitySignalService.playSignal(state.kind === 'ghostText' ? AccessibilitySignal.inlineSuggestion : AccessibilitySignal.nextEditSuggestion);
343
344
if (this.editor.getOption(EditorOption.screenReaderAnnounceInlineSuggestion)) {
345
if (state.kind === 'ghostText') {
346
this._provideScreenReaderUpdate(state.primaryGhostText.renderForScreenReader(lineText));
347
} else {
348
this._provideScreenReaderUpdate(''); // Only announce Alt+F2
349
}
350
}
351
}));
352
353
// TODO@hediet
354
this._register(this._configurationService.onDidChangeConfiguration(e => {
355
if (e.affectsConfiguration('accessibility.verbosity.inlineCompletions')) {
356
this.editor.updateOptions({ inlineCompletionsAccessibilityVerbose: this._configurationService.getValue('accessibility.verbosity.inlineCompletions') });
357
}
358
}));
359
this.editor.updateOptions({ inlineCompletionsAccessibilityVerbose: this._configurationService.getValue('accessibility.verbosity.inlineCompletions') });
360
361
const contextKeySvcObs = new ObservableContextKeyService(this._contextKeyService);
362
363
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.cursorInIndentation, this._cursorIsInIndentation));
364
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.hasSelection, reader => !this._editorObs.cursorSelection.read(reader)?.isEmpty()));
365
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.cursorAtInlineEdit, this.model.map((m, reader) => m?.inlineEditState?.read(reader)?.cursorAtInlineEdit.read(reader))));
366
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.tabShouldAcceptInlineEdit, this.model.map((m, r) => !!m?.tabShouldAcceptInlineEdit.read(r))));
367
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.tabShouldJumpToInlineEdit, this.model.map((m, r) => !!m?.tabShouldJumpToInlineEdit.read(r))));
368
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.inlineEditVisible, reader => this.model.read(reader)?.inlineEditState.read(reader) !== undefined));
369
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.inlineSuggestionHasIndentation,
370
reader => this.model.read(reader)?.getIndentationInfo(reader)?.startsWithIndentation
371
));
372
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.inlineSuggestionHasIndentationLessThanTabSize,
373
reader => this.model.read(reader)?.getIndentationInfo(reader)?.startsWithIndentationLessThanTabSize
374
));
375
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.suppressSuggestions, reader => {
376
const model = this.model.read(reader);
377
const state = model?.inlineCompletionState.read(reader);
378
return state?.primaryGhostText && state?.inlineSuggestion ? state.inlineSuggestion.source.inlineSuggestions.suppressSuggestions : undefined;
379
}));
380
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.inlineSuggestionAlternativeActionVisible, reader => {
381
const model = this.model.read(reader);
382
const state = model?.inlineEditState.read(reader);
383
const action = state?.inlineSuggestion.action;
384
return action && action.kind === 'edit' && action.alternativeAction !== undefined;
385
}));
386
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.inlineSuggestionVisible, reader => {
387
const model = this.model.read(reader);
388
const state = model?.inlineCompletionState.read(reader);
389
return !!state?.inlineSuggestion && state?.primaryGhostText !== undefined && !state?.primaryGhostText.isEmpty();
390
}));
391
const firstGhostTextPos = derived(this, reader => {
392
const model = this.model.read(reader);
393
const state = model?.inlineCompletionState.read(reader);
394
const primaryGhostText = state?.primaryGhostText;
395
if (!primaryGhostText || primaryGhostText.isEmpty()) {
396
return undefined;
397
}
398
const firstPartPos = new Position(primaryGhostText.lineNumber, primaryGhostText.parts[0].column);
399
return firstPartPos;
400
});
401
this._register(contextKeySvcObs.bind(InlineCompletionContextKeys.cursorBeforeGhostText, reader => {
402
const firstPartPos = firstGhostTextPos.read(reader);
403
if (!firstPartPos) {
404
return false;
405
}
406
const cursorPos = this._editorObs.cursorPosition.read(reader);
407
if (!cursorPos) {
408
return false;
409
}
410
return firstPartPos.equals(cursorPos);
411
}));
412
413
this._register(this._instantiationService.createInstance(TextModelChangeRecorder, this.editor));
414
}
415
416
public playAccessibilitySignal(tx: ITransaction) {
417
this._playAccessibilitySignal.trigger(tx);
418
}
419
420
private _provideScreenReaderUpdate(content: string): void {
421
const accessibleViewShowing = this._contextKeyService.getContextKeyValue<boolean>('accessibleViewIsShown');
422
const accessibleViewKeybinding = this._keybindingService.lookupKeybinding('editor.action.accessibleView');
423
let hint: string | undefined;
424
if (!accessibleViewShowing && accessibleViewKeybinding && this.editor.getOption(EditorOption.inlineCompletionsAccessibilityVerbose)) {
425
hint = localize('showAccessibleViewHint', "Inspect this in the accessible view ({0})", accessibleViewKeybinding.getAriaLabel());
426
}
427
alert(hint ? content + ', ' + hint : content);
428
}
429
430
public shouldShowHoverAt(range: Range) {
431
const ghostText = this.model.get()?.primaryGhostText.get();
432
if (!ghostText) {
433
return false;
434
}
435
return ghostText.parts.some(p => range.containsPosition(new Position(ghostText.lineNumber, p.column)));
436
}
437
438
public shouldShowHoverAtViewZone(viewZoneId: string): boolean {
439
return this._view.get().shouldShowHoverAtViewZone(viewZoneId);
440
}
441
442
public reject(): void {
443
transaction(tx => {
444
const m = this.model.get();
445
if (m) {
446
m.stop('explicitCancel', tx);
447
// Only if this controller is in focus can we cancel others.
448
if (this._focusIsInEditorOrMenu.get()) {
449
for (const ctrl of InlineCompletionsController._instances) {
450
if (ctrl !== this && !ctrl._focusIsInEditorOrMenu.get()) {
451
ctrl.model.get()?.stop('automatic', tx);
452
}
453
}
454
}
455
}
456
});
457
}
458
459
public jump(): void {
460
const m = this.model.get();
461
if (m) {
462
m.jump();
463
}
464
}
465
}
466
467