Path: blob/main/src/vs/workbench/contrib/mcp/browser/mcpServerWidgets.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 * 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 { ColorScheme } 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 { mcpServerIcon, mcpServerRemoteIcon, mcpServerWorkspaceIcon, mcpStarredIcon } from './mcpServerIcons.js';25import { MarkdownString } from '../../../../base/common/htmlContent.js';26import { renderMarkdown } from '../../../../base/browser/markdownRenderer.js';27import { onUnexpectedError } from '../../../../base/common/errors.js';28import { ExtensionHoverOptions, ExtensionIconBadge } from '../../extensions/browser/extensionsWidgets.js';29import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';30import { LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js';31import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';32import { registerColor } from '../../../../platform/theme/common/colorUtils.js';33import { textLinkForeground } from '../../../../platform/theme/common/colorRegistry.js';3435export abstract class McpServerWidget extends Disposable implements IMcpServerContainer {36private _mcpServer: IWorkbenchMcpServer | null = null;37get mcpServer(): IWorkbenchMcpServer | null { return this._mcpServer; }38set mcpServer(mcpServer: IWorkbenchMcpServer | null) { this._mcpServer = mcpServer; this.update(); }39update(): void { this.render(); }40abstract render(): void;41}4243export function onClick(element: HTMLElement, callback: () => void): IDisposable {44const disposables: DisposableStore = new DisposableStore();45disposables.add(dom.addDisposableListener(element, dom.EventType.CLICK, dom.finalHandler(callback)));46disposables.add(dom.addDisposableListener(element, dom.EventType.KEY_UP, e => {47const keyboardEvent = new StandardKeyboardEvent(e);48if (keyboardEvent.equals(KeyCode.Space) || keyboardEvent.equals(KeyCode.Enter)) {49e.preventDefault();50e.stopPropagation();51callback();52}53}));54return disposables;55}5657export class McpServerIconWidget extends McpServerWidget {5859private readonly disposables = this._register(new DisposableStore());60private readonly element: HTMLElement;61private readonly iconElement: HTMLImageElement;62private readonly codiconIconElement: HTMLElement;6364private iconUrl: string | undefined;6566constructor(67container: HTMLElement,68@IThemeService private readonly themeService: IThemeService69) {70super();71this.element = dom.append(container, dom.$('.extension-icon'));7273this.iconElement = dom.append(this.element, dom.$('img.icon', { alt: '' }));74this.iconElement.style.display = 'none';7576this.codiconIconElement = dom.append(this.element, dom.$(ThemeIcon.asCSSSelector(mcpServerIcon)));77this.codiconIconElement.style.display = 'none';7879this.render();80this._register(toDisposable(() => this.clear()));81this._register(this.themeService.onDidColorThemeChange(() => this.render()));82}8384private clear(): void {85this.iconUrl = undefined;86this.iconElement.src = '';87this.iconElement.style.display = 'none';88this.codiconIconElement.style.display = 'none';89this.codiconIconElement.className = ThemeIcon.asClassName(mcpServerIcon);90this.disposables.clear();91}9293render(): void {94if (!this.mcpServer) {95this.clear();96return;97}9899if (this.mcpServer.icon) {100this.iconElement.style.display = 'inherit';101this.codiconIconElement.style.display = 'none';102const type = this.themeService.getColorTheme().type;103const iconUrl = type === ColorScheme.DARK || ColorScheme.HIGH_CONTRAST_DARK ? this.mcpServer.icon.dark : this.mcpServer.icon.light;104if (this.iconUrl !== iconUrl) {105this.iconUrl = iconUrl;106this.disposables.add(dom.addDisposableListener(this.iconElement, 'error', () => {107this.iconElement.style.display = 'none';108this.codiconIconElement.style.display = 'inherit';109}, { once: true }));110this.iconElement.src = this.iconUrl;111if (!this.iconElement.complete) {112this.iconElement.style.visibility = 'hidden';113this.iconElement.onload = () => this.iconElement.style.visibility = 'inherit';114} else {115this.iconElement.style.visibility = 'inherit';116}117}118} else {119this.iconUrl = undefined;120this.iconElement.style.display = 'none';121this.iconElement.src = '';122this.codiconIconElement.className = this.mcpServer.codicon ? `codicon ${this.mcpServer.codicon}` : ThemeIcon.asClassName(mcpServerIcon);123this.codiconIconElement.style.display = 'inherit';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.setAttribute('role', 'button');172this.element.tabIndex = 0;173174this.containerHover = this.disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.element, localize('publisher', "Publisher ({0})", this.mcpServer.publisherDisplayName)));175dom.append(this.element, publisherDisplayName);176177if (this.mcpServer.gallery?.publisherDomain?.verified) {178dom.append(this.element, verifiedPublisher);179const publisherDomainLink = URI.parse(this.mcpServer.gallery?.publisherDomain.link);180verifiedPublisher.tabIndex = 0;181verifiedPublisher.setAttribute('role', 'button');182this.containerHover.update(localize('verified publisher', "This publisher has verified ownership of {0}", this.mcpServer.gallery?.publisherDomain.link));183verifiedPublisher.setAttribute('role', 'link');184185dom.append(verifiedPublisher, dom.$('span.extension-verified-publisher-domain', undefined, publisherDomainLink.authority.startsWith('www.') ? publisherDomainLink.authority.substring(4) : publisherDomainLink.authority));186this.disposables.add(onClick(verifiedPublisher, () => this.openerService.open(publisherDomainLink)));187}188}189190}191192}193194export class StarredWidget extends McpServerWidget {195196private readonly disposables = this._register(new DisposableStore());197198constructor(199readonly container: HTMLElement,200private small: boolean,201) {202super();203this.container.classList.add('extension-ratings');204if (this.small) {205container.classList.add('small');206}207208this.render();209this._register(toDisposable(() => this.clear()));210}211212private clear(): void {213this.container.innerText = '';214this.disposables.clear();215}216217render(): void {218this.clear();219220if (!this.mcpServer?.starsCount) {221return;222}223224if (this.small && this.mcpServer.installState !== McpServerInstallState.Uninstalled) {225return;226}227228const parent = this.small ? this.container : dom.append(this.container, dom.$('span.rating', { tabIndex: 0 }));229dom.append(parent, dom.$('span' + ThemeIcon.asCSSSelector(mcpStarredIcon)));230231const ratingCountElement = dom.append(parent, dom.$('span.count', undefined, StarredWidget.getCountLabel(this.mcpServer.starsCount)));232if (!this.small) {233ratingCountElement.style.paddingLeft = '3px';234}235}236237static getCountLabel(starsCount: number): string {238if (starsCount > 1000000) {239return `${Math.floor(starsCount / 100000) / 10}M`;240} else if (starsCount > 1000) {241return `${Math.floor(starsCount / 1000)}K`;242} else {243return String(starsCount);244}245}246247}248249export class McpServerHoverWidget extends McpServerWidget {250251private readonly hover = this._register(new MutableDisposable<IDisposable>());252253constructor(254private readonly options: ExtensionHoverOptions,255private readonly mcpServerStatusAction: McpServerStatusAction,256@IHoverService private readonly hoverService: IHoverService,257@IConfigurationService private readonly configurationService: IConfigurationService,258) {259super();260}261262render(): void {263this.hover.value = undefined;264if (this.mcpServer) {265this.hover.value = this.hoverService.setupManagedHover({266delay: this.configurationService.getValue<number>('workbench.hover.delay'),267showHover: (options, focus) => {268return this.hoverService.showInstantHover({269...options,270additionalClasses: ['extension-hover'],271position: {272hoverPosition: this.options.position(),273forcePosition: true,274},275persistence: {276hideOnKeyDown: true,277}278}, focus);279},280placement: 'element'281},282this.options.target,283{284markdown: () => Promise.resolve(this.getHoverMarkdown()),285markdownNotSupportedFallback: undefined286},287{288appearance: {289showHoverHint: true290}291}292);293}294}295296private getHoverMarkdown(): MarkdownString | undefined {297if (!this.mcpServer) {298return undefined;299}300const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true });301302markdown.appendMarkdown(`**${this.mcpServer.label}**`);303markdown.appendText(`\n`);304305let addSeparator = false;306if (this.mcpServer.local?.scope === LocalMcpServerScope.Workspace) {307markdown.appendMarkdown(`$(${mcpServerWorkspaceIcon.id}) `);308markdown.appendMarkdown(localize('workspace extension', "Workspace MCP Server"));309addSeparator = true;310}311312if (this.mcpServer.local?.scope === LocalMcpServerScope.RemoteUser) {313markdown.appendMarkdown(`$(${mcpServerRemoteIcon.id}) `);314markdown.appendMarkdown(localize('remote user extension', "Remote MCP Server"));315addSeparator = true;316}317318if (this.mcpServer.installState === McpServerInstallState.Installed) {319if (this.mcpServer.starsCount) {320if (addSeparator) {321markdown.appendText(` | `);322}323const starsCountLabel = StarredWidget.getCountLabel(this.mcpServer.starsCount);324markdown.appendMarkdown(`$(${mcpStarredIcon.id}) ${starsCountLabel}`);325addSeparator = true;326}327}328329if (addSeparator) {330markdown.appendText(`\n`);331}332333if (this.mcpServer.description) {334markdown.appendMarkdown(`${this.mcpServer.description}`);335}336337const extensionStatus = this.mcpServerStatusAction.status;338339if (extensionStatus.length) {340341markdown.appendMarkdown(`---`);342markdown.appendText(`\n`);343344for (const status of extensionStatus) {345if (status.icon) {346markdown.appendMarkdown(`$(${status.icon.id}) `);347}348markdown.appendMarkdown(status.message.value);349markdown.appendText(`\n`);350}351352}353354return markdown;355}356357}358359export class McpServerScopeBadgeWidget extends McpServerWidget {360361private readonly badge = this._register(new MutableDisposable<ExtensionIconBadge>());362private element: HTMLElement;363364constructor(365readonly container: HTMLElement,366@IInstantiationService private readonly instantiationService: IInstantiationService367) {368super();369this.element = dom.append(this.container, dom.$(''));370this.render();371this._register(toDisposable(() => this.clear()));372}373374private clear(): void {375this.badge.value?.element.remove();376this.badge.clear();377}378379render(): void {380this.clear();381382const scope = this.mcpServer?.local?.scope;383384if (!scope || scope === LocalMcpServerScope.User) {385return;386}387388let icon: ThemeIcon;389switch (scope) {390case LocalMcpServerScope.Workspace: {391icon = mcpServerWorkspaceIcon;392break;393}394case LocalMcpServerScope.RemoteUser: {395icon = mcpServerRemoteIcon;396break;397}398}399400this.badge.value = this.instantiationService.createInstance(ExtensionIconBadge, icon, undefined);401dom.append(this.element, this.badge.value.element);402}403}404405export class McpServerStatusWidget extends McpServerWidget {406407private readonly renderDisposables = this._register(new MutableDisposable());408409private readonly _onDidRender = this._register(new Emitter<void>());410readonly onDidRender: Event<void> = this._onDidRender.event;411412constructor(413private readonly container: HTMLElement,414private readonly extensionStatusAction: McpServerStatusAction,415@IOpenerService private readonly openerService: IOpenerService,416) {417super();418this.render();419this._register(extensionStatusAction.onDidChangeStatus(() => this.render()));420}421422render(): void {423reset(this.container);424this.renderDisposables.value = undefined;425const disposables = new DisposableStore();426this.renderDisposables.value = disposables;427const extensionStatus = this.extensionStatusAction.status;428if (extensionStatus.length) {429const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true });430for (let i = 0; i < extensionStatus.length; i++) {431const status = extensionStatus[i];432if (status.icon) {433markdown.appendMarkdown(`$(${status.icon.id}) `);434}435markdown.appendMarkdown(status.message.value);436if (i < extensionStatus.length - 1) {437markdown.appendText(`\n`);438}439}440const rendered = disposables.add(renderMarkdown(markdown, {441actionHandler: (content) => {442this.openerService.open(content, { allowCommands: true }).catch(onUnexpectedError);443}444}));445dom.append(this.container, rendered.element);446}447this._onDidRender.fire();448}449}450451export const mcpStarredIconColor = registerColor('mcpIcon.starForeground', { light: '#DF6100', dark: '#FF8E00', hcDark: '#FF8E00', hcLight: textLinkForeground }, localize('mcpIconStarForeground', "The icon color for mcp starred."), false);452453registerThemingParticipant((theme, collector) => {454const mcpStarredIconColorValue = theme.getColor(mcpStarredIconColor);455if (mcpStarredIconColorValue) {456collector.addRule(`.extension-ratings .codicon-mcp-server-starred { color: ${mcpStarredIconColorValue}; }`);457collector.addRule(`.monaco-hover.extension-hover .markdown-hover .hover-contents ${ThemeIcon.asCSSSelector(mcpStarredIcon)} { color: ${mcpStarredIconColorValue}; }`);458}459});460461462463