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