Path: blob/main/src/vs/editor/contrib/codeAction/browser/codeActionModel.ts
3296 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 { CancelablePromise, createCancelablePromise, TimeoutTimer } from '../../../../base/common/async.js';6import { isCancellationError } from '../../../../base/common/errors.js';7import { Emitter } from '../../../../base/common/event.js';8import { HierarchicalKind } from '../../../../base/common/hierarchicalKind.js';9import { Disposable, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js';10import { isEqual } from '../../../../base/common/resources.js';11import { URI } from '../../../../base/common/uri.js';12import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';13import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';14import { IMarkerService } from '../../../../platform/markers/common/markers.js';15import { IEditorProgressService, Progress } from '../../../../platform/progress/common/progress.js';16import { ICodeEditor } from '../../../browser/editorBrowser.js';17import { EditorOption, ShowLightbulbIconMode } from '../../../common/config/editorOptions.js';18import { Position } from '../../../common/core/position.js';19import { Selection } from '../../../common/core/selection.js';20import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js';21import { CodeActionProvider, CodeActionTriggerType } from '../../../common/languages.js';22import { CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSource } from '../common/types.js';23import { getCodeActions } from './codeAction.js';2425export const SUPPORTED_CODE_ACTIONS = new RawContextKey<string>('supportedCodeAction', '');2627export const APPLY_FIX_ALL_COMMAND_ID = '_typescript.applyFixAllCodeAction';2829type TriggeredCodeAction = {30readonly selection: Selection;31readonly trigger: CodeActionTrigger;32};3334class CodeActionOracle extends Disposable {3536private readonly _autoTriggerTimer = this._register(new TimeoutTimer());3738constructor(39private readonly _editor: ICodeEditor,40private readonly _markerService: IMarkerService,41private readonly _signalChange: (triggered: TriggeredCodeAction | undefined) => void,42private readonly _delay: number = 250,43) {44super();45this._register(this._markerService.onMarkerChanged(e => this._onMarkerChanges(e)));46this._register(this._editor.onDidChangeCursorPosition(() => this._tryAutoTrigger()));47}4849public trigger(trigger: CodeActionTrigger): void {50const selection = this._getRangeOfSelectionUnlessWhitespaceEnclosed(trigger);51this._signalChange(selection ? { trigger, selection } : undefined);52}5354private _onMarkerChanges(resources: readonly URI[]): void {55const model = this._editor.getModel();56if (model && resources.some(resource => isEqual(resource, model.uri))) {57this._tryAutoTrigger();58}59}6061private _tryAutoTrigger() {62this._autoTriggerTimer.cancelAndSet(() => {63this.trigger({ type: CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default });64}, this._delay);65}6667private _getRangeOfSelectionUnlessWhitespaceEnclosed(trigger: CodeActionTrigger): Selection | undefined {68if (!this._editor.hasModel()) {69return undefined;70}71const selection = this._editor.getSelection();72if (trigger.type === CodeActionTriggerType.Invoke) {73return selection;74}75const enabled = this._editor.getOption(EditorOption.lightbulb).enabled;76if (enabled === ShowLightbulbIconMode.Off) {77return undefined;78} else if (enabled === ShowLightbulbIconMode.On) {79return selection;80} else if (enabled === ShowLightbulbIconMode.OnCode) {81const isSelectionEmpty = selection.isEmpty();82if (!isSelectionEmpty) {83return selection;84}85const model = this._editor.getModel();86const { lineNumber, column } = selection.getPosition();87const line = model.getLineContent(lineNumber);88if (line.length === 0) {89// empty line90return undefined;91} else if (column === 1) {92// look only right93if (/\s/.test(line[0])) {94return undefined;95}96} else if (column === model.getLineMaxColumn(lineNumber)) {97// look only left98if (/\s/.test(line[line.length - 1])) {99return undefined;100}101} else {102// look left and right103if (/\s/.test(line[column - 2]) && /\s/.test(line[column - 1])) {104return undefined;105}106}107}108return selection;109}110}111112export namespace CodeActionsState {113114export const enum Type { Empty, Triggered }115116export const Empty = { type: Type.Empty } as const;117118export class Triggered {119readonly type = Type.Triggered;120121public readonly actions: Promise<CodeActionSet>;122123constructor(124public readonly trigger: CodeActionTrigger,125public readonly position: Position,126private readonly _cancellablePromise: CancelablePromise<CodeActionSet>,127) {128this.actions = _cancellablePromise.catch((e): CodeActionSet => {129if (isCancellationError(e)) {130return emptyCodeActionSet;131}132throw e;133});134}135136public cancel() {137this._cancellablePromise.cancel();138}139}140141export type State = typeof Empty | Triggered;142}143144const emptyCodeActionSet = Object.freeze<CodeActionSet>({145allActions: [],146validActions: [],147dispose: () => { },148documentation: [],149hasAutoFix: false,150hasAIFix: false,151allAIFixes: false,152});153154155export class CodeActionModel extends Disposable {156157private readonly _codeActionOracle = this._register(new MutableDisposable<CodeActionOracle>());158private _state: CodeActionsState.State = CodeActionsState.Empty;159160private readonly _supportedCodeActions: IContextKey<string>;161162private readonly _onDidChangeState = this._register(new Emitter<CodeActionsState.State>());163public readonly onDidChangeState = this._onDidChangeState.event;164165private readonly codeActionsDisposable: MutableDisposable<IDisposable> = this._register(new MutableDisposable());166167private _disposed = false;168169constructor(170private readonly _editor: ICodeEditor,171private readonly _registry: LanguageFeatureRegistry<CodeActionProvider>,172private readonly _markerService: IMarkerService,173contextKeyService: IContextKeyService,174private readonly _progressService?: IEditorProgressService,175private readonly _configurationService?: IConfigurationService,176) {177super();178this._supportedCodeActions = SUPPORTED_CODE_ACTIONS.bindTo(contextKeyService);179180this._register(this._editor.onDidChangeModel(() => this._update()));181this._register(this._editor.onDidChangeModelLanguage(() => this._update()));182this._register(this._registry.onDidChange(() => this._update()));183this._register(this._editor.onDidChangeConfiguration((e) => {184if (e.hasChanged(EditorOption.lightbulb)) {185this._update();186}187}));188this._update();189}190191override dispose(): void {192if (this._disposed) {193return;194}195this._disposed = true;196197super.dispose();198this.setState(CodeActionsState.Empty, true);199}200201private _settingEnabledNearbyQuickfixes(): boolean {202const model = this._editor?.getModel();203return this._configurationService ? this._configurationService.getValue('editor.codeActionWidget.includeNearbyQuickFixes', { resource: model?.uri }) : false;204}205206private _update(): void {207if (this._disposed) {208return;209}210211this._codeActionOracle.value = undefined;212213this.setState(CodeActionsState.Empty);214215const model = this._editor.getModel();216if (model217&& this._registry.has(model)218&& !this._editor.getOption(EditorOption.readOnly)219) {220const supportedActions: string[] = this._registry.all(model).flatMap(provider => provider.providedCodeActionKinds ?? []);221this._supportedCodeActions.set(supportedActions.join(' '));222223this._codeActionOracle.value = new CodeActionOracle(this._editor, this._markerService, trigger => {224if (!trigger) {225this.setState(CodeActionsState.Empty);226return;227}228229const startPosition = trigger.selection.getStartPosition();230231const actions = createCancelablePromise(async token => {232if (this._settingEnabledNearbyQuickfixes() && trigger.trigger.type === CodeActionTriggerType.Invoke && (trigger.trigger.triggerAction === CodeActionTriggerSource.QuickFix || trigger.trigger.filter?.include?.contains(CodeActionKind.QuickFix))) {233const codeActionSet = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token);234this.codeActionsDisposable.value = codeActionSet;235const allCodeActions = [...codeActionSet.allActions];236if (token.isCancellationRequested) {237codeActionSet.dispose();238return emptyCodeActionSet;239}240241// Search for non-AI quickfixes in the current code action set - if AI code actions are the only thing found, continue searching for diagnostics in line.242const foundQuickfix = codeActionSet.validActions?.some(action => {243return action.action.kind &&244CodeActionKind.QuickFix.contains(new HierarchicalKind(action.action.kind)) &&245!action.action.isAI;246});247const allMarkers = this._markerService.read({ resource: model.uri });248if (foundQuickfix) {249for (const action of codeActionSet.validActions) {250if (action.action.command?.arguments?.some(arg => typeof arg === 'string' && arg.includes(APPLY_FIX_ALL_COMMAND_ID))) {251action.action.diagnostics = [...allMarkers.filter(marker => marker.relatedInformation)];252}253}254return { validActions: codeActionSet.validActions, allActions: allCodeActions, documentation: codeActionSet.documentation, hasAutoFix: codeActionSet.hasAutoFix, hasAIFix: codeActionSet.hasAIFix, allAIFixes: codeActionSet.allAIFixes, dispose: () => { this.codeActionsDisposable.value = codeActionSet; } };255} else if (!foundQuickfix) {256// If markers exist, and there are no quickfixes found or length is zero, check for quickfixes on that line.257if (allMarkers.length > 0) {258const currPosition = trigger.selection.getPosition();259let trackedPosition = currPosition;260let distance = Number.MAX_VALUE;261const currentActions = [...codeActionSet.validActions];262263for (const marker of allMarkers) {264const col = marker.endColumn;265const row = marker.endLineNumber;266const startRow = marker.startLineNumber;267268// Found quickfix on the same line and check relative distance to other markers269if ((row === currPosition.lineNumber || startRow === currPosition.lineNumber)) {270trackedPosition = new Position(row, col);271const newCodeActionTrigger: CodeActionTrigger = {272type: trigger.trigger.type,273triggerAction: trigger.trigger.triggerAction,274filter: { include: trigger.trigger.filter?.include ? trigger.trigger.filter?.include : CodeActionKind.QuickFix },275autoApply: trigger.trigger.autoApply,276context: { notAvailableMessage: trigger.trigger.context?.notAvailableMessage || '', position: trackedPosition }277};278279const selectionAsPosition = new Selection(trackedPosition.lineNumber, trackedPosition.column, trackedPosition.lineNumber, trackedPosition.column);280const actionsAtMarker = await getCodeActions(this._registry, model, selectionAsPosition, newCodeActionTrigger, Progress.None, token);281if (token.isCancellationRequested) {282actionsAtMarker.dispose();283return emptyCodeActionSet;284}285286if (actionsAtMarker.validActions.length !== 0) {287for (const action of actionsAtMarker.validActions) {288if (action.action.command?.arguments?.some(arg => typeof arg === 'string' && arg.includes(APPLY_FIX_ALL_COMMAND_ID))) {289action.action.diagnostics = [...allMarkers.filter(marker => marker.relatedInformation)];290}291}292293if (codeActionSet.allActions.length === 0) {294allCodeActions.push(...actionsAtMarker.allActions);295}296297// Already filtered through to only get quickfixes, so no need to filter again.298if (Math.abs(currPosition.column - col) < distance) {299currentActions.unshift(...actionsAtMarker.validActions);300} else {301currentActions.push(...actionsAtMarker.validActions);302}303}304distance = Math.abs(currPosition.column - col);305}306}307const filteredActions = currentActions.filter((action, index, self) =>308self.findIndex((a) => a.action.title === action.action.title) === index);309310filteredActions.sort((a, b) => {311if (a.action.isPreferred && !b.action.isPreferred) {312return -1;313} else if (!a.action.isPreferred && b.action.isPreferred) {314return 1;315} else if (a.action.isAI && !b.action.isAI) {316return 1;317} else if (!a.action.isAI && b.action.isAI) {318return -1;319} else {320return 0;321}322});323324// Only retriggers if actually found quickfix on the same line as cursor325return { validActions: filteredActions, allActions: allCodeActions, documentation: codeActionSet.documentation, hasAutoFix: codeActionSet.hasAutoFix, hasAIFix: codeActionSet.hasAIFix, allAIFixes: codeActionSet.allAIFixes, dispose: () => { this.codeActionsDisposable.value = codeActionSet; } };326}327}328}329330// Case for manual triggers - specifically Source Actions and Refactors331if (trigger.trigger.type === CodeActionTriggerType.Invoke) {332const codeActions = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token);333this.codeActionsDisposable.value = codeActions;334return codeActions;335}336337const codeActionSet = await getCodeActions(this._registry, model, trigger.selection, trigger.trigger, Progress.None, token);338this.codeActionsDisposable.value = codeActionSet;339return codeActionSet;340});341342if (trigger.trigger.type === CodeActionTriggerType.Invoke) {343this._progressService?.showWhile(actions, 250);344}345const newState = new CodeActionsState.Triggered(trigger.trigger, startPosition, actions);346let isManualToAutoTransition = false;347if (this._state.type === CodeActionsState.Type.Triggered) {348// Check if the current state is manual and the new state is automatic349isManualToAutoTransition = this._state.trigger.type === CodeActionTriggerType.Invoke &&350newState.type === CodeActionsState.Type.Triggered &&351newState.trigger.type === CodeActionTriggerType.Auto &&352this._state.position !== newState.position;353}354355// Do not trigger state if current state is manual and incoming state is automatic356if (!isManualToAutoTransition) {357this.setState(newState);358} else {359// Reset the new state after getting code actions back.360setTimeout(() => {361this.setState(newState);362}, 500);363}364}, undefined);365this._codeActionOracle.value.trigger({ type: CodeActionTriggerType.Auto, triggerAction: CodeActionTriggerSource.Default });366} else {367this._supportedCodeActions.reset();368}369}370371public trigger(trigger: CodeActionTrigger) {372this._codeActionOracle.value?.trigger(trigger);373this.codeActionsDisposable.dispose();374}375376private setState(newState: CodeActionsState.State, skipNotify?: boolean) {377if (newState === this._state) {378return;379}380381// Cancel old request382if (this._state.type === CodeActionsState.Type.Triggered) {383this._state.cancel();384}385386this._state = newState;387388if (!skipNotify && !this._disposed) {389this._onDidChangeState.fire(newState);390}391}392}393394395