Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpServerWidgets.ts
5272 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 { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';7import { IManagedHover } from '../../../../base/browser/ui/hover/hover.js';8import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';9import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';10import { KeyCode } from '../../../../base/common/keyCodes.js';11import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';12import { ThemeIcon } from '../../../../base/common/themables.js';13import { URI } from '../../../../base/common/uri.js';14import { localize } from '../../../../nls.js';15import { IHoverService } from '../../../../platform/hover/browser/hover.js';16import { IOpenerService } from '../../../../platform/opener/common/opener.js';17import { verifiedPublisherIcon } from '../../../services/extensionManagement/common/extensionsIcons.js';18import { IMcpServerContainer, IWorkbenchMcpServer, McpServerInstallState } from '../common/mcpTypes.js';19import { IThemeService, registerThemingParticipant } from '../../../../platform/theme/common/themeService.js';20import { isDark } from '../../../../platform/theme/common/theme.js';21import { Emitter, Event } from '../../../../base/common/event.js';22import { McpServerStatusAction } from './mcpServerActions.js';23import { reset } from '../../../../base/browser/dom.js';24import { mcpLicenseIcon, mcpServerIcon, mcpServerRemoteIcon, mcpServerWorkspaceIcon, mcpStarredIcon } from './mcpServerIcons.js';25import { MarkdownString } from '../../../../base/common/htmlContent.js';26import { ExtensionHoverOptions, ExtensionIconBadge } from '../../extensions/browser/extensionsWidgets.js';27import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';28import { LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js';29import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';30import { registerColor } from '../../../../platform/theme/common/colorUtils.js';31import { textLinkForeground } from '../../../../platform/theme/common/colorRegistry.js';32import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js';3334export abstract class McpServerWidget extends Disposable implements IMcpServerContainer {35private _mcpServer: IWorkbenchMcpServer | null = null;36get mcpServer(): IWorkbenchMcpServer | null { return this._mcpServer; }37set mcpServer(mcpServer: IWorkbenchMcpServer | null) { this._mcpServer = mcpServer; this.update(); }38update(): void { this.render(); }39abstract render(): void;40}4142export function onClick(element: HTMLElement, callback: () => void): IDisposable {43const disposables: DisposableStore = new DisposableStore();44disposables.add(dom.addDisposableListener(element, dom.EventType.CLICK, dom.finalHandler(callback)));45disposables.add(dom.addDisposableListener(element, dom.EventType.KEY_UP, e => {46const keyboardEvent = new StandardKeyboardEvent(e);47if (keyboardEvent.equals(KeyCode.Space) || keyboardEvent.equals(KeyCode.Enter)) {48e.preventDefault();49e.stopPropagation();50callback();51}52}));53return disposables;54}5556export class McpServerIconWidget extends McpServerWidget {5758private readonly iconLoadingDisposable = this._register(new MutableDisposable());59private readonly element: HTMLElement;60private readonly iconElement: HTMLImageElement;61private readonly codiconIconElement: HTMLElement;6263private iconUrl: string | undefined;6465constructor(66container: HTMLElement,67@IThemeService private readonly themeService: IThemeService68) {69super();70this.element = dom.append(container, dom.$('.extension-icon'));7172this.iconElement = dom.append(this.element, dom.$('img.icon', { alt: '' }));73this.iconElement.style.display = 'none';7475this.codiconIconElement = dom.append(this.element, dom.$(ThemeIcon.asCSSSelector(mcpServerIcon)));76this.codiconIconElement.style.display = 'none';7778this.render();79this._register(toDisposable(() => this.clear()));80this._register(this.themeService.onDidColorThemeChange(() => this.render()));81}8283private clear(): void {84this.iconUrl = undefined;85this.iconElement.src = '';86this.iconElement.style.display = 'none';87this.codiconIconElement.style.display = 'none';88this.codiconIconElement.className = ThemeIcon.asClassName(mcpServerIcon);89this.iconLoadingDisposable.clear();90}9192render(): void {93if (!this.mcpServer) {94this.clear();95return;96}9798if (this.mcpServer.icon) {99const type = this.themeService.getColorTheme().type;100const iconUrl = isDark(type) ? this.mcpServer.icon.dark : this.mcpServer.icon.light;101if (this.iconUrl !== iconUrl) {102this.iconElement.style.display = 'inherit';103this.codiconIconElement.style.display = 'none';104this.iconUrl = iconUrl;105this.iconLoadingDisposable.value = dom.addDisposableListener(this.iconElement, 'error', () => {106this.iconElement.style.display = 'none';107this.codiconIconElement.style.display = 'inherit';108}, { once: true });109this.iconElement.src = this.iconUrl;110if (!this.iconElement.complete) {111this.iconElement.style.visibility = 'hidden';112this.iconElement.onload = () => this.iconElement.style.visibility = 'inherit';113} else {114this.iconElement.style.visibility = 'inherit';115}116}117} else {118this.iconUrl = undefined;119this.iconElement.style.display = 'none';120this.iconElement.src = '';121this.codiconIconElement.className = this.mcpServer.codicon ? `codicon ${this.mcpServer.codicon}` : ThemeIcon.asClassName(mcpServerIcon);122this.codiconIconElement.style.display = 'inherit';123this.iconLoadingDisposable.clear();124}125}126}127128export class PublisherWidget extends McpServerWidget {129130private element: HTMLElement | undefined;131private containerHover: IManagedHover | undefined;132133private readonly disposables = this._register(new DisposableStore());134135constructor(136readonly container: HTMLElement,137private small: boolean,138@IHoverService private readonly hoverService: IHoverService,139@IOpenerService private readonly openerService: IOpenerService,140) {141super();142143this.render();144this._register(toDisposable(() => this.clear()));145}146147private clear(): void {148this.element?.remove();149this.disposables.clear();150}151152render(): void {153this.clear();154if (!this.mcpServer?.publisherDisplayName) {155return;156}157158this.element = dom.append(this.container, dom.$('.publisher'));159const publisherDisplayName = dom.$('.publisher-name.ellipsis');160publisherDisplayName.textContent = this.mcpServer.publisherDisplayName;161162const verifiedPublisher = dom.$('.verified-publisher');163dom.append(verifiedPublisher, dom.$('span.extension-verified-publisher.clickable'), renderIcon(verifiedPublisherIcon));164165if (this.small) {166if (this.mcpServer.gallery?.publisherDomain?.verified) {167dom.append(this.element, verifiedPublisher);168}169dom.append(this.element, publisherDisplayName);170} else {171this.element.classList.toggle('clickable', !!this.mcpServer.gallery?.publisherUrl);172this.element.setAttribute('role', 'button');173this.element.tabIndex = 0;174175this.containerHover = this.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.element, localize('publisher', "Publisher ({0})", this.mcpServer.publisherDisplayName)));176dom.append(this.element, publisherDisplayName);177178if (this.mcpServer.gallery?.publisherDomain?.verified) {179dom.append(this.element, verifiedPublisher);180const publisherDomainLink = URI.parse(this.mcpServer.gallery?.publisherDomain.link);181verifiedPublisher.tabIndex = 0;182verifiedPublisher.setAttribute('role', 'button');183this.containerHover.update(localize('verified publisher', "This publisher has verified ownership of {0}", this.mcpServer.gallery?.publisherDomain.link));184verifiedPublisher.setAttribute('role', 'link');185186dom.append(verifiedPublisher, dom.$('span.extension-verified-publisher-domain', undefined, publisherDomainLink.authority.startsWith('www.') ? publisherDomainLink.authority.substring(4) : publisherDomainLink.authority));187this.disposables.add(onClick(verifiedPublisher, () => this.openerService.open(publisherDomainLink)));188}189190if (this.mcpServer.gallery?.publisherUrl) {191this.disposables.add(onClick(this.element, () => this.openerService.open(this.mcpServer?.gallery?.publisherUrl!)));192}193}194195}196197}198199export class StarredWidget extends McpServerWidget {200201private readonly disposables = this._register(new DisposableStore());202203constructor(204readonly container: HTMLElement,205private small: boolean,206) {207super();208this.container.classList.add('extension-ratings');209if (this.small) {210container.classList.add('small');211}212213this.render();214this._register(toDisposable(() => this.clear()));215}216217private clear(): void {218this.container.innerText = '';219this.disposables.clear();220}221222render(): void {223this.clear();224225if (!this.mcpServer?.starsCount) {226return;227}228229if (this.small && this.mcpServer.installState !== McpServerInstallState.Uninstalled) {230return;231}232233const parent = this.small ? this.container : dom.append(this.container, dom.$('span.rating', { tabIndex: 0 }));234dom.append(parent, dom.$('span' + ThemeIcon.asCSSSelector(mcpStarredIcon)));235236const ratingCountElement = dom.append(parent, dom.$('span.count', undefined, StarredWidget.getCountLabel(this.mcpServer.starsCount)));237if (!this.small) {238ratingCountElement.style.paddingLeft = '3px';239}240}241242static getCountLabel(starsCount: number): string {243if (starsCount > 1000000) {244return `${Math.floor(starsCount / 100000) / 10}M`;245} else if (starsCount > 1000) {246return `${Math.floor(starsCount / 1000)}K`;247} else {248return String(starsCount);249}250}251252}253254export class LicenseWidget extends McpServerWidget {255256private readonly disposables = this._register(new DisposableStore());257258constructor(259readonly container: HTMLElement,260) {261super();262this.container.classList.add('license');263this.render();264this._register(toDisposable(() => this.clear()));265}266267private clear(): void {268this.container.innerText = '';269this.disposables.clear();270}271272render(): void {273this.clear();274275if (!this.mcpServer?.license) {276return;277}278279const parent = dom.append(this.container, dom.$('span.license', { tabIndex: 0 }));280dom.append(parent, dom.$('span' + ThemeIcon.asCSSSelector(mcpLicenseIcon)));281282const licenseElement = dom.append(parent, dom.$('span', undefined, this.mcpServer.license));283licenseElement.style.paddingLeft = '3px';284}285}286287export class McpServerHoverWidget extends McpServerWidget {288289private readonly hover = this._register(new MutableDisposable<IDisposable>());290291constructor(292private readonly options: ExtensionHoverOptions,293private readonly mcpServerStatusAction: McpServerStatusAction,294@IHoverService private readonly hoverService: IHoverService,295@IConfigurationService private readonly configurationService: IConfigurationService,296) {297super();298}299300render(): void {301this.hover.value = undefined;302if (this.mcpServer) {303this.hover.value = this.hoverService.setupManagedHover({304delay: this.configurationService.getValue<number>('workbench.hover.delay'),305showHover: (options, focus) => {306return this.hoverService.showInstantHover({307...options,308additionalClasses: ['extension-hover'],309position: {310hoverPosition: this.options.position(),311forcePosition: true,312},313persistence: {314hideOnKeyDown: true,315}316}, focus);317},318placement: 'element'319},320this.options.target,321{322markdown: () => Promise.resolve(this.getHoverMarkdown()),323markdownNotSupportedFallback: undefined324},325{326appearance: {327showHoverHint: true328}329}330);331}332}333334private getHoverMarkdown(): MarkdownString | undefined {335if (!this.mcpServer) {336return undefined;337}338const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true });339340markdown.appendMarkdown(`**${this.mcpServer.label}**`);341markdown.appendText(`\n`);342343let addSeparator = false;344if (this.mcpServer.local?.scope === LocalMcpServerScope.Workspace) {345markdown.appendMarkdown(`$(${mcpServerWorkspaceIcon.id}) `);346markdown.appendMarkdown(localize('workspace extension', "Workspace MCP Server"));347addSeparator = true;348}349350if (this.mcpServer.local?.scope === LocalMcpServerScope.RemoteUser) {351markdown.appendMarkdown(`$(${mcpServerRemoteIcon.id}) `);352markdown.appendMarkdown(localize('remote user extension', "Remote MCP Server"));353addSeparator = true;354}355356if (this.mcpServer.installState === McpServerInstallState.Installed) {357if (this.mcpServer.starsCount) {358if (addSeparator) {359markdown.appendText(` | `);360}361const starsCountLabel = StarredWidget.getCountLabel(this.mcpServer.starsCount);362markdown.appendMarkdown(`$(${mcpStarredIcon.id}) ${starsCountLabel}`);363addSeparator = true;364}365}366367if (addSeparator) {368markdown.appendText(`\n`);369}370371if (this.mcpServer.description) {372markdown.appendMarkdown(`${this.mcpServer.description}`);373}374375const extensionStatus = this.mcpServerStatusAction.status;376377if (extensionStatus.length) {378379markdown.appendMarkdown(`---`);380markdown.appendText(`\n`);381382for (const status of extensionStatus) {383if (status.icon) {384markdown.appendMarkdown(`$(${status.icon.id}) `);385}386markdown.appendMarkdown(status.message.value);387markdown.appendText(`\n`);388}389390}391392return markdown;393}394395}396397export class McpServerScopeBadgeWidget extends McpServerWidget {398399private readonly badge = this._register(new MutableDisposable<ExtensionIconBadge>());400private element: HTMLElement;401402constructor(403readonly container: HTMLElement,404@IInstantiationService private readonly instantiationService: IInstantiationService405) {406super();407this.element = dom.append(this.container, dom.$(''));408this.render();409this._register(toDisposable(() => this.clear()));410}411412private clear(): void {413this.badge.value?.element.remove();414this.badge.clear();415}416417render(): void {418this.clear();419420const scope = this.mcpServer?.local?.scope;421422if (!scope || scope === LocalMcpServerScope.User) {423return;424}425426let icon: ThemeIcon;427switch (scope) {428case LocalMcpServerScope.Workspace: {429icon = mcpServerWorkspaceIcon;430break;431}432case LocalMcpServerScope.RemoteUser: {433icon = mcpServerRemoteIcon;434break;435}436}437438this.badge.value = this.instantiationService.createInstance(ExtensionIconBadge, icon, undefined);439dom.append(this.element, this.badge.value.element);440}441}442443export class McpServerStatusWidget extends McpServerWidget {444445private readonly renderDisposables = this._register(new MutableDisposable());446447private readonly _onDidRender = this._register(new Emitter<void>());448readonly onDidRender: Event<void> = this._onDidRender.event;449450constructor(451private readonly container: HTMLElement,452private readonly extensionStatusAction: McpServerStatusAction,453@IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService,454) {455super();456this.render();457this._register(extensionStatusAction.onDidChangeStatus(() => this.render()));458}459460render(): void {461reset(this.container);462this.renderDisposables.value = undefined;463const disposables = new DisposableStore();464this.renderDisposables.value = disposables;465const extensionStatus = this.extensionStatusAction.status;466if (extensionStatus.length) {467const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true });468for (let i = 0; i < extensionStatus.length; i++) {469const status = extensionStatus[i];470if (status.icon) {471markdown.appendMarkdown(`$(${status.icon.id}) `);472}473markdown.appendMarkdown(status.message.value);474if (i < extensionStatus.length - 1) {475markdown.appendText(`\n`);476}477}478const rendered = disposables.add(this.markdownRendererService.render(markdown));479dom.append(this.container, rendered.element);480}481this._onDidRender.fire();482}483}484485export const mcpStarredIconColor = registerColor('mcpIcon.starForeground', { light: '#DF6100', dark: '#FF8E00', hcDark: '#FF8E00', hcLight: textLinkForeground }, localize('mcpIconStarForeground', "The icon color for mcp starred."), false);486487registerThemingParticipant((theme, collector) => {488const mcpStarredIconColorValue = theme.getColor(mcpStarredIconColor);489if (mcpStarredIconColorValue) {490collector.addRule(`.extension-ratings .codicon-mcp-server-starred { color: ${mcpStarredIconColorValue}; }`);491collector.addRule(`.monaco-hover.extension-hover .markdown-hover .hover-contents ${ThemeIcon.asCSSSelector(mcpStarredIcon)} { color: ${mcpStarredIconColorValue}; }`);492}493});494495496497