Path: blob/main/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts
4779 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 * as dom from '../../../../base/browser/dom.js';6import { asArray, compareBy, numberComparator } from '../../../../base/common/arrays.js';7import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';8import { IMarkdownString, isEmptyMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js';9import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';10import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js';11import { DECREASE_HOVER_VERBOSITY_ACTION_ID, INCREASE_HOVER_VERBOSITY_ACTION_ID } from './hoverActionIds.js';12import { ICodeEditor } from '../../../browser/editorBrowser.js';13import { Position } from '../../../common/core/position.js';14import { Range } from '../../../common/core/range.js';15import { IModelDecoration, ITextModel } from '../../../common/model.js';16import { HoverAnchor, HoverAnchorType, HoverRangeAnchor, IEditorHoverParticipant, IEditorHoverRenderContext, IHoverPart, IRenderedHoverPart, IRenderedHoverParts, RenderedHoverParts } from './hoverTypes.js';17import * as nls from '../../../../nls.js';18import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';19import { ILanguageFeaturesService } from '../../../common/services/languageFeatures.js';20import { EditorOption } from '../../../common/config/editorOptions.js';21import { Hover, HoverContext, HoverProvider, HoverVerbosityAction } from '../../../common/languages.js';22import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';23import { Codicon } from '../../../../base/common/codicons.js';24import { ThemeIcon } from '../../../../base/common/themables.js';25import { onUnexpectedExternalError } from '../../../../base/common/errors.js';26import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';27import { ClickAction, HoverPosition, KeyDownAction } from '../../../../base/browser/ui/hover/hoverWidget.js';28import { KeyCode } from '../../../../base/common/keyCodes.js';29import { IHoverService, WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js';30import { AsyncIterableProducer } from '../../../../base/common/async.js';31import { LanguageFeatureRegistry } from '../../../common/languageFeatureRegistry.js';32import { getHoverProviderResultsAsAsyncIterable } from './getHover.js';33import { ICommandService } from '../../../../platform/commands/common/commands.js';34import { HoverStartSource } from './hoverOperation.js';35import { ScrollEvent } from '../../../../base/common/scrollable.js';3637const $ = dom.$;38const increaseHoverVerbosityIcon = registerIcon('hover-increase-verbosity', Codicon.add, nls.localize('increaseHoverVerbosity', 'Icon for increaseing hover verbosity.'));39const decreaseHoverVerbosityIcon = registerIcon('hover-decrease-verbosity', Codicon.remove, nls.localize('decreaseHoverVerbosity', 'Icon for decreasing hover verbosity.'));4041export class MarkdownHover implements IHoverPart {4243constructor(44public readonly owner: IEditorHoverParticipant<MarkdownHover>,45public readonly range: Range,46public readonly contents: IMarkdownString[],47public readonly isBeforeContent: boolean,48public readonly ordinal: number,49public readonly source: HoverSource | undefined = undefined,50) { }5152public isValidForHoverAnchor(anchor: HoverAnchor): boolean {53return (54anchor.type === HoverAnchorType.Range55&& this.range.startColumn <= anchor.range.startColumn56&& this.range.endColumn >= anchor.range.endColumn57);58}59}6061class HoverSource {6263constructor(64readonly hover: Hover,65readonly hoverProvider: HoverProvider,66readonly hoverPosition: Position,67) { }6869public supportsVerbosityAction(hoverVerbosityAction: HoverVerbosityAction): boolean {70switch (hoverVerbosityAction) {71case HoverVerbosityAction.Increase:72return this.hover.canIncreaseVerbosity ?? false;73case HoverVerbosityAction.Decrease:74return this.hover.canDecreaseVerbosity ?? false;75}76}77}7879export class MarkdownHoverParticipant implements IEditorHoverParticipant<MarkdownHover> {8081public readonly hoverOrdinal: number = 3;8283private _renderedHoverParts: MarkdownRenderedHoverParts | undefined;8485constructor(86protected readonly _editor: ICodeEditor,87@IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService,88@IConfigurationService private readonly _configurationService: IConfigurationService,89@ILanguageFeaturesService protected readonly _languageFeaturesService: ILanguageFeaturesService,90@IKeybindingService private readonly _keybindingService: IKeybindingService,91@IHoverService private readonly _hoverService: IHoverService,92@ICommandService private readonly _commandService: ICommandService,93) { }9495public createLoadingMessage(anchor: HoverAnchor): MarkdownHover | null {96return new MarkdownHover(this, anchor.range, [new MarkdownString().appendText(nls.localize('modesContentHover.loading', "Loading..."))], false, 2000);97}9899public computeSync(anchor: HoverAnchor, lineDecorations: IModelDecoration[]): MarkdownHover[] {100if (!this._editor.hasModel() || anchor.type !== HoverAnchorType.Range) {101return [];102}103104const model = this._editor.getModel();105const lineNumber = anchor.range.startLineNumber;106const maxColumn = model.getLineMaxColumn(lineNumber);107const result: MarkdownHover[] = [];108109let index = 1000;110111const lineLength = model.getLineLength(lineNumber);112const languageId = model.getLanguageIdAtPosition(anchor.range.startLineNumber, anchor.range.startColumn);113const stopRenderingLineAfter = this._editor.getOption(EditorOption.stopRenderingLineAfter);114const maxTokenizationLineLength = this._configurationService.getValue<number>('editor.maxTokenizationLineLength', {115overrideIdentifier: languageId116});117let stopRenderingMessage = false;118if (stopRenderingLineAfter >= 0 && lineLength > stopRenderingLineAfter && anchor.range.startColumn >= stopRenderingLineAfter) {119stopRenderingMessage = true;120result.push(new MarkdownHover(this, anchor.range, [{121value: nls.localize('stopped rendering', "Rendering paused for long line for performance reasons. This can be configured via `editor.stopRenderingLineAfter`.")122}], false, index++));123}124if (!stopRenderingMessage && typeof maxTokenizationLineLength === 'number' && lineLength >= maxTokenizationLineLength) {125result.push(new MarkdownHover(this, anchor.range, [{126value: nls.localize('too many characters', "Tokenization is skipped for long lines for performance reasons. This can be configured via `editor.maxTokenizationLineLength`.")127}], false, index++));128}129130let isBeforeContent = false;131132for (const d of lineDecorations) {133const startColumn = (d.range.startLineNumber === lineNumber) ? d.range.startColumn : 1;134const endColumn = (d.range.endLineNumber === lineNumber) ? d.range.endColumn : maxColumn;135136const hoverMessage = d.options.hoverMessage;137if (!hoverMessage || isEmptyMarkdownString(hoverMessage)) {138continue;139}140141if (d.options.beforeContentClassName) {142isBeforeContent = true;143}144145const range = new Range(anchor.range.startLineNumber, startColumn, anchor.range.startLineNumber, endColumn);146result.push(new MarkdownHover(this, range, asArray(hoverMessage), isBeforeContent, index++));147}148149return result;150}151152public computeAsync(anchor: HoverAnchor, lineDecorations: IModelDecoration[], source: HoverStartSource, token: CancellationToken): AsyncIterable<MarkdownHover> {153if (!this._editor.hasModel() || anchor.type !== HoverAnchorType.Range) {154return AsyncIterableProducer.EMPTY;155}156157const model = this._editor.getModel();158159const hoverProviderRegistry = this._languageFeaturesService.hoverProvider;160if (!hoverProviderRegistry.has(model)) {161return AsyncIterableProducer.EMPTY;162}163return this._getMarkdownHovers(hoverProviderRegistry, model, anchor, token);164}165166private async *_getMarkdownHovers(hoverProviderRegistry: LanguageFeatureRegistry<HoverProvider>, model: ITextModel, anchor: HoverRangeAnchor, token: CancellationToken): AsyncIterable<MarkdownHover> {167const position = anchor.range.getStartPosition();168const hoverProviderResults = getHoverProviderResultsAsAsyncIterable(hoverProviderRegistry, model, position, token);169170for await (const item of hoverProviderResults) {171if (!isEmptyMarkdownString(item.hover.contents)) {172const range = item.hover.range ? Range.lift(item.hover.range) : anchor.range;173const hoverSource = new HoverSource(item.hover, item.provider, position);174yield new MarkdownHover(this, range, item.hover.contents, false, item.ordinal, hoverSource);175}176}177}178179public renderHoverParts(context: IEditorHoverRenderContext, hoverParts: MarkdownHover[]): IRenderedHoverParts<MarkdownHover> {180this._renderedHoverParts = new MarkdownRenderedHoverParts(181hoverParts,182context.fragment,183this,184this._editor,185this._commandService,186this._keybindingService,187this._hoverService,188this._configurationService,189this._markdownRendererService,190context.onContentsChanged191);192return this._renderedHoverParts;193}194195public handleScroll(e: ScrollEvent): void {196this._renderedHoverParts?.handleScroll(e);197}198199public getAccessibleContent(hoverPart: MarkdownHover): string {200return this._renderedHoverParts?.getAccessibleContent(hoverPart) ?? '';201}202203public doesMarkdownHoverAtIndexSupportVerbosityAction(index: number, action: HoverVerbosityAction): boolean {204return this._renderedHoverParts?.doesMarkdownHoverAtIndexSupportVerbosityAction(index, action) ?? false;205}206207public updateMarkdownHoverVerbosityLevel(action: HoverVerbosityAction, index: number): Promise<{ hoverPart: MarkdownHover; hoverElement: HTMLElement } | undefined> {208return Promise.resolve(this._renderedHoverParts?.updateMarkdownHoverPartVerbosityLevel(action, index));209}210}211212class RenderedMarkdownHoverPart implements IRenderedHoverPart<MarkdownHover> {213214constructor(215public readonly hoverPart: MarkdownHover,216public readonly hoverElement: HTMLElement,217public readonly disposables: DisposableStore,218public readonly actionsContainer?: HTMLElement219) { }220221get hoverAccessibleContent(): string {222return this.hoverElement.innerText.trim();223}224225dispose(): void {226this.disposables.dispose();227}228}229230class MarkdownRenderedHoverParts implements IRenderedHoverParts<MarkdownHover> {231232public renderedHoverParts: RenderedMarkdownHoverPart[];233234private _ongoingHoverOperations: Map<HoverProvider, { verbosityDelta: number; tokenSource: CancellationTokenSource }> = new Map();235236private readonly _disposables = new DisposableStore();237238constructor(239hoverParts: MarkdownHover[],240hoverPartsContainer: DocumentFragment,241private readonly _hoverParticipant: MarkdownHoverParticipant,242private readonly _editor: ICodeEditor,243private readonly _commandService: ICommandService,244private readonly _keybindingService: IKeybindingService,245private readonly _hoverService: IHoverService,246private readonly _configurationService: IConfigurationService,247private readonly _markdownRendererService: IMarkdownRendererService,248private readonly _onFinishedRendering: () => void,249) {250this.renderedHoverParts = this._renderHoverParts(hoverParts, hoverPartsContainer, this._onFinishedRendering);251this._disposables.add(toDisposable(() => {252this.renderedHoverParts.forEach(renderedHoverPart => {253renderedHoverPart.dispose();254});255this._ongoingHoverOperations.forEach(operation => {256operation.tokenSource.dispose(true);257});258}));259}260261private _renderHoverParts(262hoverParts: MarkdownHover[],263hoverPartsContainer: DocumentFragment,264onFinishedRendering: () => void,265): RenderedMarkdownHoverPart[] {266hoverParts.sort(compareBy(hover => hover.ordinal, numberComparator));267return hoverParts.map(hoverPart => {268const renderedHoverPart = this._renderHoverPart(hoverPart, onFinishedRendering);269hoverPartsContainer.appendChild(renderedHoverPart.hoverElement);270return renderedHoverPart;271});272}273274private _renderHoverPart(275hoverPart: MarkdownHover,276onFinishedRendering: () => void277): RenderedMarkdownHoverPart {278279const renderedMarkdownPart = this._renderMarkdownHover(hoverPart, onFinishedRendering);280const renderedMarkdownElement = renderedMarkdownPart.hoverElement;281const hoverSource = hoverPart.source;282const disposables = new DisposableStore();283disposables.add(renderedMarkdownPart);284285if (!hoverSource) {286return new RenderedMarkdownHoverPart(hoverPart, renderedMarkdownElement, disposables);287}288289const canIncreaseVerbosity = hoverSource.supportsVerbosityAction(HoverVerbosityAction.Increase);290const canDecreaseVerbosity = hoverSource.supportsVerbosityAction(HoverVerbosityAction.Decrease);291292if (!canIncreaseVerbosity && !canDecreaseVerbosity) {293return new RenderedMarkdownHoverPart(hoverPart, renderedMarkdownElement, disposables);294}295296const actionsContainer = $('div.verbosity-actions');297renderedMarkdownElement.prepend(actionsContainer);298const actionsContainerInner = $('div.verbosity-actions-inner');299actionsContainer.append(actionsContainerInner);300disposables.add(this._renderHoverExpansionAction(actionsContainerInner, HoverVerbosityAction.Increase, canIncreaseVerbosity));301disposables.add(this._renderHoverExpansionAction(actionsContainerInner, HoverVerbosityAction.Decrease, canDecreaseVerbosity));302return new RenderedMarkdownHoverPart(hoverPart, renderedMarkdownElement, disposables, actionsContainerInner);303}304305private _renderMarkdownHover(306markdownHover: MarkdownHover,307onFinishedRendering: () => void308): IRenderedHoverPart<MarkdownHover> {309const renderedMarkdownHover = renderMarkdown(310this._editor,311markdownHover,312this._markdownRendererService,313onFinishedRendering,314);315return renderedMarkdownHover;316}317318private _renderHoverExpansionAction(container: HTMLElement, action: HoverVerbosityAction, actionEnabled: boolean): DisposableStore {319const store = new DisposableStore();320const isActionIncrease = action === HoverVerbosityAction.Increase;321const actionElement = dom.append(container, $(ThemeIcon.asCSSSelector(isActionIncrease ? increaseHoverVerbosityIcon : decreaseHoverVerbosityIcon)));322actionElement.tabIndex = 0;323const hoverDelegate = store.add(new WorkbenchHoverDelegate('mouse', undefined, { target: container, position: { hoverPosition: HoverPosition.LEFT } }, this._configurationService, this._hoverService));324store.add(this._hoverService.setupManagedHover(hoverDelegate, actionElement, labelForHoverVerbosityAction(this._keybindingService, action)));325if (!actionEnabled) {326actionElement.classList.add('disabled');327return store;328}329actionElement.classList.add('enabled');330const actionFunction = () => this._commandService.executeCommand(action === HoverVerbosityAction.Increase ? INCREASE_HOVER_VERBOSITY_ACTION_ID : DECREASE_HOVER_VERBOSITY_ACTION_ID, { focus: true });331store.add(new ClickAction(actionElement, actionFunction));332store.add(new KeyDownAction(actionElement, actionFunction, [KeyCode.Enter, KeyCode.Space]));333return store;334}335336public handleScroll(e: ScrollEvent): void {337this.renderedHoverParts.forEach(renderedHoverPart => {338const actionsContainerInner = renderedHoverPart.actionsContainer;339if (!actionsContainerInner) {340return;341}342const hoverElement = renderedHoverPart.hoverElement;343const topOfHoverScrollPosition = e.scrollTop;344const bottomOfHoverScrollPosition = topOfHoverScrollPosition + e.height;345const topOfRenderedPart = hoverElement.offsetTop;346const hoverElementHeight = hoverElement.clientHeight;347const bottomOfRenderedPart = topOfRenderedPart + hoverElementHeight;348const iconsHeight = 22;349let top: number;350if (bottomOfRenderedPart <= bottomOfHoverScrollPosition || topOfRenderedPart >= bottomOfHoverScrollPosition) {351top = hoverElementHeight - iconsHeight;352} else {353top = bottomOfHoverScrollPosition - topOfRenderedPart - iconsHeight;354}355actionsContainerInner.style.top = `${top}px`;356});357}358359public async updateMarkdownHoverPartVerbosityLevel(action: HoverVerbosityAction, index: number): Promise<{ hoverPart: MarkdownHover; hoverElement: HTMLElement } | undefined> {360const model = this._editor.getModel();361if (!model) {362return undefined;363}364const hoverRenderedPart = this._getRenderedHoverPartAtIndex(index);365const hoverSource = hoverRenderedPart?.hoverPart.source;366if (!hoverRenderedPart || !hoverSource?.supportsVerbosityAction(action)) {367return undefined;368}369const newHover = await this._fetchHover(hoverSource, model, action);370if (!newHover) {371return undefined;372}373const newHoverSource = new HoverSource(newHover, hoverSource.hoverProvider, hoverSource.hoverPosition);374const initialHoverPart = hoverRenderedPart.hoverPart;375const newHoverPart = new MarkdownHover(376this._hoverParticipant,377initialHoverPart.range,378newHover.contents,379initialHoverPart.isBeforeContent,380initialHoverPart.ordinal,381newHoverSource382);383const newHoverRenderedPart = this._updateRenderedHoverPart(index, newHoverPart);384if (!newHoverRenderedPart) {385return undefined;386}387return {388hoverPart: newHoverPart,389hoverElement: newHoverRenderedPart.hoverElement390};391}392393public getAccessibleContent(hoverPart: MarkdownHover): string | undefined {394const renderedHoverPartIndex = this.renderedHoverParts.findIndex(renderedHoverPart => renderedHoverPart.hoverPart === hoverPart);395if (renderedHoverPartIndex === -1) {396return undefined;397}398const renderedHoverPart = this._getRenderedHoverPartAtIndex(renderedHoverPartIndex);399if (!renderedHoverPart) {400return undefined;401}402const hoverElementInnerText = renderedHoverPart.hoverElement.innerText;403const accessibleContent = hoverElementInnerText.replace(/[^\S\n\r]+/gu, ' ');404return accessibleContent;405}406407public doesMarkdownHoverAtIndexSupportVerbosityAction(index: number, action: HoverVerbosityAction): boolean {408const hoverRenderedPart = this._getRenderedHoverPartAtIndex(index);409const hoverSource = hoverRenderedPart?.hoverPart.source;410if (!hoverRenderedPart || !hoverSource?.supportsVerbosityAction(action)) {411return false;412}413return true;414}415416private async _fetchHover(hoverSource: HoverSource, model: ITextModel, action: HoverVerbosityAction): Promise<Hover | null | undefined> {417let verbosityDelta = action === HoverVerbosityAction.Increase ? 1 : -1;418const provider = hoverSource.hoverProvider;419const ongoingHoverOperation = this._ongoingHoverOperations.get(provider);420if (ongoingHoverOperation) {421ongoingHoverOperation.tokenSource.cancel();422verbosityDelta += ongoingHoverOperation.verbosityDelta;423}424const tokenSource = new CancellationTokenSource();425this._ongoingHoverOperations.set(provider, { verbosityDelta, tokenSource });426const context: HoverContext = { verbosityRequest: { verbosityDelta, previousHover: hoverSource.hover } };427let hover: Hover | null | undefined;428try {429hover = await Promise.resolve(provider.provideHover(model, hoverSource.hoverPosition, tokenSource.token, context));430} catch (e) {431onUnexpectedExternalError(e);432}433tokenSource.dispose();434this._ongoingHoverOperations.delete(provider);435return hover;436}437438private _updateRenderedHoverPart(index: number, hoverPart: MarkdownHover): RenderedMarkdownHoverPart | undefined {439if (index >= this.renderedHoverParts.length || index < 0) {440return undefined;441}442const renderedHoverPart = this._renderHoverPart(hoverPart, this._onFinishedRendering);443const currentRenderedHoverPart = this.renderedHoverParts[index];444const currentRenderedMarkdown = currentRenderedHoverPart.hoverElement;445const renderedMarkdown = renderedHoverPart.hoverElement;446const renderedChildrenElements = Array.from(renderedMarkdown.children);447currentRenderedMarkdown.replaceChildren(...renderedChildrenElements);448const newRenderedHoverPart = new RenderedMarkdownHoverPart(449hoverPart,450currentRenderedMarkdown,451renderedHoverPart.disposables,452renderedHoverPart.actionsContainer453);454currentRenderedHoverPart.dispose();455this.renderedHoverParts[index] = newRenderedHoverPart;456return newRenderedHoverPart;457}458459private _getRenderedHoverPartAtIndex(index: number): RenderedMarkdownHoverPart | undefined {460return this.renderedHoverParts[index];461}462463public dispose(): void {464this._disposables.dispose();465}466}467468export function renderMarkdownHovers(469context: IEditorHoverRenderContext,470markdownHovers: MarkdownHover[],471editor: ICodeEditor,472markdownRendererService: IMarkdownRendererService,473): IRenderedHoverParts<MarkdownHover> {474475// Sort hover parts to keep them stable since they might come in async, out-of-order476markdownHovers.sort(compareBy(hover => hover.ordinal, numberComparator));477const renderedHoverParts: IRenderedHoverPart<MarkdownHover>[] = [];478for (const markdownHover of markdownHovers) {479const renderedHoverPart = renderMarkdown(480editor,481markdownHover,482markdownRendererService,483context.onContentsChanged,484);485context.fragment.appendChild(renderedHoverPart.hoverElement);486renderedHoverParts.push(renderedHoverPart);487}488return new RenderedHoverParts(renderedHoverParts);489}490491function renderMarkdown(492editor: ICodeEditor,493markdownHover: MarkdownHover,494markdownRendererService: IMarkdownRendererService,495onFinishedRendering: () => void,496): IRenderedHoverPart<MarkdownHover> {497const disposables = new DisposableStore();498const renderedMarkdown = $('div.hover-row');499const renderedMarkdownContents = $('div.hover-row-contents');500renderedMarkdown.appendChild(renderedMarkdownContents);501const markdownStrings = markdownHover.contents;502for (const markdownString of markdownStrings) {503if (isEmptyMarkdownString(markdownString)) {504continue;505}506const markdownHoverElement = $('div.markdown-hover');507const hoverContentsElement = dom.append(markdownHoverElement, $('div.hover-contents'));508509const renderedContents = disposables.add(markdownRendererService.render(markdownString, {510context: editor,511asyncRenderCallback: () => {512hoverContentsElement.className = 'hover-contents code-hover-contents';513onFinishedRendering();514}515}));516hoverContentsElement.appendChild(renderedContents.element);517renderedMarkdownContents.appendChild(markdownHoverElement);518}519const renderedHoverPart: IRenderedHoverPart<MarkdownHover> = {520hoverPart: markdownHover,521hoverElement: renderedMarkdown,522dispose() { disposables.dispose(); }523};524return renderedHoverPart;525}526527export function labelForHoverVerbosityAction(keybindingService: IKeybindingService, action: HoverVerbosityAction): string {528switch (action) {529case HoverVerbosityAction.Increase:530return keybindingService.appendKeybinding(nls.localize('increaseVerbosity', "Increase Hover Verbosity"), INCREASE_HOVER_VERBOSITY_ACTION_ID);531case HoverVerbosityAction.Decrease:532return keybindingService.appendKeybinding(nls.localize('decreaseVerbosity', "Decrease Hover Verbosity"), DECREASE_HOVER_VERBOSITY_ACTION_ID);533}534}535536537