diff --git a/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts b/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts index 6a43da003ed5a6..b4cdab99836c1f 100644 --- a/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts +++ b/src/vs/platform/agentFinder/common/agentFinderRestProvider.ts @@ -122,7 +122,7 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide } private async resolveMcpIcons(page: ICustomizationMarketplaceSourcePage, token: CancellationToken): Promise { - if (!page.items.some(item => !item.icon && item.installation?.kind === 'mcp' && item.externalUrl)) { + if (!page.items.some(item => (!item.icon || !item.publisher) && item.installation?.kind === 'mcp' && item.externalUrl)) { return page; } @@ -137,15 +137,15 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide try { const items = await Promise.all(page.items.map(item => - !item.icon && item.installation?.kind === 'mcp' && item.externalUrl - ? limiter.queue(() => this.resolveMcpIcon(item, token, cancellation.token)) + (!item.icon || !item.publisher) && item.installation?.kind === 'mcp' && item.externalUrl + ? limiter.queue(() => this.resolveMcpMetadata(item, token, cancellation.token)) : item )); if (token.isCancellationRequested) { throw new CancellationError(); } if (timedOut) { - this.logService.warn('[AgentFinderRestProvider] Timed out resolving MCP catalog icons.'); + this.logService.warn('[AgentFinderRestProvider] Timed out resolving MCP catalog metadata.'); } return { ...page, items }; } finally { @@ -154,7 +154,7 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide } } - private async resolveMcpIcon(item: ICustomizationMarketplaceEntry, queryToken: CancellationToken, iconToken: CancellationToken): Promise { + private async resolveMcpMetadata(item: ICustomizationMarketplaceEntry, queryToken: CancellationToken, iconToken: CancellationToken): Promise { const installation = item.installation; if (installation?.kind !== 'mcp' || !item.externalUrl || queryToken.isCancellationRequested || iconToken.isCancellationRequested) { return item; @@ -166,10 +166,16 @@ export class AgentFinderRestProvider implements ICustomizationMarketplaceProvide if (server?.icon?.light && !icon) { this.logService.warn(`[AgentFinderRestProvider] Ignoring an invalid MCP catalog icon for '${installation.name}'.`); } - return icon ? { ...item, icon } : item; + const publisher = server?.publisherDisplayName ?? server?.publisher; + const repository = githubRepository(parseHttpUri(server?.repositoryUrl)); + const repositoryOwner = repository?.path.split('/')[1]; + const publisherUrl = parseHttpUri(server?.publisherUrl) ?? (repositoryOwner ? URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${repositoryOwner}` }) : undefined); + return icon || publisher || publisherUrl || repository + ? { ...item, ...(icon ? { icon } : {}), ...(publisher ? { publisher } : {}), ...(publisherUrl ? { publisherUrl } : {}), ...(repository ? { repository } : {}) } + : item; } catch (error) { if (!queryToken.isCancellationRequested && !iconToken.isCancellationRequested) { - this.logService.warn(`[AgentFinderRestProvider] Failed to resolve the MCP catalog icon for '${installation.name}'.`, error); + this.logService.warn(`[AgentFinderRestProvider] Failed to resolve MCP catalog metadata for '${installation.name}'.`, error); } return item; } @@ -301,6 +307,7 @@ function parseResource(value: unknown): ICustomizationMarketplaceEntry { const sourceSet = text(metadata?.sourceSet); const repository = (sourceSet && !sourceSet.includes('://') ? githubRepository(parseHttpUri(`https://github.com/${sourceSet}`), true) : undefined) ?? githubRepository(url); const publisher = repository?.path.split('/')[1]; + const publisherUrl = publisher ? URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${publisher}` }) : undefined; const installation = parseInstallation(mediaType, metadata, url, externalUrl); const readmeUri = getReadmeUri(installation); return { @@ -317,6 +324,7 @@ function parseResource(value: unknown): ICustomizationMarketplaceEntry { ...(readmeUri ? { readmeUri } : {}), icon: publisher ? URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${publisher}.png`, query: 'size=64' }) : undefined, publisher, + ...(publisherUrl ? { publisherUrl } : {}), version: text(value.version) ?? text(metadata?.version), score: value.score, installation, diff --git a/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts b/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts index 14d332a44df421..43cb50abc7772f 100644 --- a/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts +++ b/src/vs/platform/agentFinder/test/common/agentFinderRestProvider.test.ts @@ -96,6 +96,7 @@ function resourceSnapshot(resource: ICustomizationMarketplaceEntry) { url: resource.url?.toString(), repository: resource.repository?.toString(), icon: resource.icon?.toString(), + ...(resource.publisherUrl ? { publisherUrl: resource.publisherUrl.toString() } : {}), }; } @@ -141,6 +142,7 @@ suite('AgentFinderRestProvider', () => { repository: 'https://github.com/ChromeDevTools/chrome-devtools-mcp', icon: 'https://github.com/ChromeDevTools.png?size%3D64', publisher: 'ChromeDevTools', + publisherUrl: 'https://github.com/ChromeDevTools', version: undefined, score: undefined, installation: { kind: 'skill', repository: 'ChromeDevTools/chrome-devtools-mcp', ref: 'main', path: 'skills/a11y-debugging' }, @@ -159,7 +161,7 @@ suite('AgentFinderRestProvider', () => { }); }); - test('resolves an MCP registry icon through the fixed Agent Finder registry', async () => { + test('resolves MCP registry metadata through the fixed Agent Finder registry', async () => { const lookups: { url: string; manifestUrl: string | undefined; manifestVersion: string | undefined; cancelled: boolean }[] = []; const mcpGalleryService = upcastPartial({ getMcpServer: async (url, manifest, token) => { @@ -174,6 +176,9 @@ suite('AgentFinderRestProvider', () => { light: 'https://avatars.githubusercontent.com/u/213697801', dark: 'https://avatars.githubusercontent.com/u/213697801', }, + publisher: 'pgEdge', + publisherDisplayName: 'pgEdge, Inc.', + repositoryUrl: 'https://github.com/pgEdge/postgres-mcp', }); }, }); @@ -187,8 +192,9 @@ suite('AgentFinderRestProvider', () => { version: page.items[0].version, url: page.items[0].url?.toString(), externalUrl: page.items[0].externalUrl, - repository: page.items[0].repository, + repository: page.items[0].repository?.toString(), publisher: page.items[0].publisher, + publisherUrl: page.items[0].publisherUrl?.toString(), icon: page.items[0].icon?.toString(), stars: page.items[0].stars, }, @@ -200,8 +206,9 @@ suite('AgentFinderRestProvider', () => { version: '1.0.0', url: 'https://api.mcp.github.com/oss/v0.1/servers/io.github.pgEdge/postgres-mcp/versions/latest', externalUrl: mcpServer.url, - repository: undefined, - publisher: undefined, + repository: 'https://github.com/pgEdge/postgres-mcp', + publisher: 'pgEdge, Inc.', + publisherUrl: 'https://github.com/pgEdge', icon: 'https://avatars.githubusercontent.com/u/213697801', stars: undefined, }, @@ -214,7 +221,7 @@ suite('AgentFinderRestProvider', () => { }); }); - test('keeps the MCP result and logs when registry icon resolution fails', async () => { + test('keeps the MCP result and logs when registry metadata resolution fails', async () => { const mcpGalleryService = upcastPartial({ getMcpServer: async () => { throw new Error('offline'); }, }); @@ -228,7 +235,7 @@ suite('AgentFinderRestProvider', () => { warnings: warning.args.map(args => args[0]), }, { icon: undefined, - warnings: ['[AgentFinderRestProvider] Failed to resolve the MCP catalog icon for \'io.github.pgEdge/postgres-mcp\'.'], + warnings: ['[AgentFinderRestProvider] Failed to resolve MCP catalog metadata for \'io.github.pgEdge/postgres-mcp\'.'], }); }); @@ -252,7 +259,7 @@ suite('AgentFinderRestProvider', () => { }, { icon: undefined, iconCancelled: true, - warnings: ['[AgentFinderRestProvider] Timed out resolving MCP catalog icons.'], + warnings: ['[AgentFinderRestProvider] Timed out resolving MCP catalog metadata.'], }); })); @@ -751,8 +758,8 @@ suite('AgentFinderRestProvider', () => { const page = await service.query({}, CancellationToken.None); const item = page.items[0]; - assert.deepStrictEqual({ repository: item.repository?.toString(), icon: item.icon?.toString(), publisher: item.publisher, version: item.version }, { - repository: 'https://github.com/Owner/Repository', icon: 'https://github.com/Owner.png?size%3D64', publisher: 'Owner', version: '2.0', + assert.deepStrictEqual({ repository: item.repository?.toString(), icon: item.icon?.toString(), publisher: item.publisher, publisherUrl: item.publisherUrl?.toString(), version: item.version }, { + repository: 'https://github.com/Owner/Repository', icon: 'https://github.com/Owner.png?size%3D64', publisher: 'Owner', publisherUrl: 'https://github.com/Owner', version: '2.0', }); }); diff --git a/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts b/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts index 900f1920f536ed..d0f5175461cdd9 100644 --- a/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts +++ b/src/vs/platform/customizationMarketplace/common/customizationMarketplaceService.ts @@ -83,6 +83,7 @@ export interface ICustomizationMarketplaceEntry { readonly readmeUri?: URI; readonly icon?: CustomizationMarketplaceIcon; readonly publisher?: string; + readonly publisherUrl?: URI; /** Source-supplied origin within a feed, distinct from the feed's display name. */ readonly originLabel?: string; readonly version?: string; diff --git a/src/vs/platform/customizationMarketplace/common/mcpGalleryMarketplaceProvider.ts b/src/vs/platform/customizationMarketplace/common/mcpGalleryMarketplaceProvider.ts index e99c0d4b499259..fdcdfea61c8bfe 100644 --- a/src/vs/platform/customizationMarketplace/common/mcpGalleryMarketplaceProvider.ts +++ b/src/vs/platform/customizationMarketplace/common/mcpGalleryMarketplaceProvider.ts @@ -71,6 +71,7 @@ function toMarketplaceEntry(server: IGalleryMcpServer, registry: 'custom' | 'def const url = webUrl ?? repository; const lightIcon = safeWebUri(server.icon?.light); const darkIcon = safeWebUri(server.icon?.dark); + const publisherUrl = safeWebUri(server.publisherUrl); const icon: CustomizationMarketplaceIcon | undefined = lightIcon && darkIcon ? { light: lightIcon, dark: darkIcon } : lightIcon ?? darkIcon; @@ -86,6 +87,7 @@ function toMarketplaceEntry(server: IGalleryMcpServer, registry: 'custom' | 'def ...(repository ? { repository } : {}), ...(icon ? { icon } : {}), publisher: server.publisherDisplayName ?? server.publisher, + ...(publisherUrl ? { publisherUrl } : {}), version: server.version, stars: server.starsCount, priority: registry === 'custom' ? 1 : 0, diff --git a/src/vs/platform/customizationMarketplace/test/common/mcpGalleryMarketplaceProvider.test.ts b/src/vs/platform/customizationMarketplace/test/common/mcpGalleryMarketplaceProvider.test.ts index ded9ea83f20c8b..7130d53bb4db33 100644 --- a/src/vs/platform/customizationMarketplace/test/common/mcpGalleryMarketplaceProvider.test.ts +++ b/src/vs/platform/customizationMarketplace/test/common/mcpGalleryMarketplaceProvider.test.ts @@ -26,6 +26,7 @@ suite('McpGalleryMarketplaceProvider', () => { isLatest: true, status: GalleryMcpServerStatus.Active, publisher: 'Owner', + publisherUrl: 'https://registry.test/publishers/owner', topics: ['database'], starsCount: 42, webUrl: 'https://registry.test/servers/io.github.owner%2Fserver', @@ -81,13 +82,13 @@ suite('McpGalleryMarketplaceProvider', () => { url: first.items[0].url, externalUrl: server.webUrl, repository: first.items[0].repository, icon: first.items[0].icon, - publisher: 'Owner', version: '1.0.0', stars: 42, priority: 1, + publisher: 'Owner', publisherUrl: first.items[0].publisherUrl, version: '1.0.0', stars: 42, priority: 1, installation: { kind: 'mcpGallery', name: server.name, registry: 'custom', registryUrl: customUrl }, }], total: 2, nextCursor: 'opaque+/=', }, lastCursor: undefined, - entryFields: ['identifier', 'displayName', 'description', 'mediaType', 'tags', 'capabilities', 'representativeQueries', 'url', 'externalUrl', 'repository', 'icon', 'publisher', 'version', 'stars', 'priority', 'installation'], + entryFields: ['identifier', 'displayName', 'description', 'mediaType', 'tags', 'capabilities', 'representativeQueries', 'url', 'externalUrl', 'repository', 'icon', 'publisher', 'publisherUrl', 'version', 'stars', 'priority', 'installation'], iconUris: { light: 'https://registry.test/icon-light.png', dark: 'https://registry.test/icon-dark.png', diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts index e77b09338befcf..5a2047b4656579 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/copilotConnectorsService.ts @@ -894,6 +894,7 @@ function scoreConnectorField(word: string, value: string): number | undefined { } export function toCopilotConnectorMarketplaceEntry(connector: ICopilotConnector): ICustomizationMarketplaceEntry { + const publisherUrl = connector.homepage ?? connector.author?.url; return { identifier: connector.name, displayName: connector.displayName, @@ -906,6 +907,7 @@ export function toCopilotConnectorMarketplaceEntry(connector: ICopilotConnector) externalUrl: connector.documentation?.toString(true), icon: connector.icon, publisher: localize('copilotConnectors.publisher', "GitHub Copilot"), + ...(publisherUrl ? { publisherUrl } : {}), installation: { kind: 'copilotConnector', name: connector.name }, }; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplace.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplace.contribution.ts index f25ae32afd3c34..eeb232412380f6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplace.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationMarketplace.contribution.ts @@ -65,7 +65,7 @@ class CustomizationDiscoveryAccessibleView implements IAccessibleViewImplementat localize('customizationDiscovery.help.browse', "Clear the search to browse. Show All on a section applies its type filter and moves to search results."), localize('customizationDiscovery.help.sources', "Use the source picker to search all available marketplace feeds or one feed. Configure Marketplaces opens the related settings."), localize('customizationDiscovery.help.navigation', "Use Tab and Shift+Tab between controls. In search results, use the arrow keys, Home, and End to navigate the list. Press Enter or Space on an available item to open its in-product details. Press Enter to open an installed item."), - localize('customizationDiscovery.help.install', "Review an available item's source, then choose Install. VS Code records the exact installed target instead of associating same-name local items. If recorded skill files are missing, choose Repair to restore only those files without overwriting existing files. Installed marketplace items provide an Uninstall action. MCP servers that require unsupported local prerequisites provide View Setup to open the publisher's instructions instead. VS Code continues to apply destination, trust, policy, and compatibility checks."), + localize('customizationDiscovery.help.install', "Review an available item's source, then choose Install. For connectors, choose Connect, Reconnect, or Disconnect to manage the connection. VS Code records the exact installed target instead of associating same-name local items. If recorded skill files are missing, choose Repair to restore only those files without overwriting existing files. Installed marketplace items provide an Uninstall action. MCP servers that require unsupported local prerequisites provide View Setup to open the publisher's instructions instead. VS Code continues to apply destination, trust, policy, and compatibility checks."), localize('customizationDiscovery.help.links', "Available customization names no longer open external resources. Open an item's details to review its metadata, then use the explicitly labeled resource or repository actions for external navigation. Back or Escape returns to Discover and restores focus."), localize('customizationDiscovery.help.paging', "Scrolling near the end of search results loads another page without removing loaded items. Retry repeats a failed marketplace request."), localize('customizationDiscovery.help.sourceFailures', "Unavailable sources show a warning and Retry action above the results. Scrolling continues healthy sources. Retrying a source reloads all sources from the first page to restore relevance order."), diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts index 18d399c8b1eea4..75b3ae27fce22c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/embeddedMarketplaceDetail.ts @@ -14,10 +14,12 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../../b import { Codicon } from '../../../../../base/common/codicons.js'; import { Schemas } from '../../../../../base/common/network.js'; import { isEqual } from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { CustomizationMarketplaceMediaType, ICustomizationMarketplaceResource } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { asTextOrError, IRequestService } from '../../../../../platform/request/common/request.js'; import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -44,7 +46,12 @@ export class EmbeddedMarketplaceDetail extends Disposable { private readonly titleEl: HTMLElement; private readonly titleActionsEl: HTMLElement; private readonly descriptionEl: HTMLElement; - private readonly stateEl: HTMLElement; + private readonly publisherEl: HTMLAnchorElement; + private readonly installStateEl: HTMLElement; + private readonly installStateCardEl: HTMLElement; + private readonly installStateIconEl: HTMLElement; + private readonly installStateSummaryEl: HTMLElement; + private readonly installStateDetailsEl: HTMLElement; private readonly queriesEl: HTMLElement; private readonly factsEl: HTMLElement; private readonly readmeEl: HTMLElement; @@ -65,6 +72,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { @ICustomizationMarketplaceInstallService private readonly installService: ICustomizationMarketplaceInstallService, @INotificationService private readonly notificationService: INotificationService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IHoverService private readonly hoverService: IHoverService, @IThemeService private readonly themeService: IThemeService, @IRequestService private readonly requestService: IRequestService, ) { @@ -76,11 +84,21 @@ export class EmbeddedMarketplaceDetail extends Disposable { const identity = DOM.append(header, $('.embedded-detail-header-text')); const nameRow = DOM.append(identity, $('.embedded-detail-name-row')); this.titleEl = DOM.append(nameRow, $('h2.embedded-detail-name')); - this.stateEl = DOM.append(nameRow, $('.inline-badge.embedded-detail-status-badge')); - this.stateEl.setAttribute('role', 'status'); + this.publisherEl = DOM.append(nameRow, $('a.embedded-detail-publisher')) as HTMLAnchorElement; this.titleActionsEl = DOM.append(header, $('.embedded-detail-title-actions')); this.descriptionEl = DOM.append(this.root, $('p.embedded-detail-description.marketplace-detail-description')); + this.installStateEl = DOM.append(this.root, $('section.mcp-detail-diagnostics.marketplace-detail-install-state')); + this.installStateEl.style.display = 'none'; + const installStateSection = DOM.append(this.installStateEl, $('section.mcp-detail-diagnostic-section')); + this.installStateCardEl = DOM.append(installStateSection, $('.mcp-detail-diagnostic-card')); + const installStateHeader = DOM.append(this.installStateCardEl, $('.mcp-detail-diagnostic-header')); + this.installStateIconEl = DOM.append(installStateHeader, $('.mcp-detail-diagnostic-icon')); + this.installStateIconEl.setAttribute('aria-hidden', 'true'); + this.installStateSummaryEl = DOM.append(installStateHeader, $('.mcp-detail-diagnostic-summary')); + this.installStateSummaryEl.setAttribute('aria-live', 'polite'); + this.installStateDetailsEl = DOM.append(this.installStateCardEl, $('.mcp-detail-diagnostic-details')); + const queriesSection = DOM.append(this.root, $('section.embedded-detail-section.marketplace-detail-queries')); DOM.append(queriesSection, $('h3.embedded-detail-section-title')).textContent = localize('marketplaceDetail.tryThis', "Try this"); this.queriesEl = DOM.append(queriesSection, $('ul.marketplace-detail-query-list')); @@ -124,7 +142,10 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.readmeContent = undefined; this.titleEl.textContent = ''; this.descriptionEl.textContent = ''; - this.stateEl.textContent = ''; + this.publisherEl.textContent = ''; + this.publisherEl.style.display = 'none'; + this.publisherEl.removeAttribute('href'); + this.installStateEl.style.display = 'none'; this.iconDisposables.clear(); DOM.clearNode(this.iconEl); DOM.clearNode(this.titleActionsEl); @@ -141,10 +162,12 @@ export class EmbeddedMarketplaceDetail extends Disposable { } const state = this.installService.getInstallState(resource); const target = getLocationTarget(state); + const statePresentation = getInstallStatePresentation(state); return [ resource.displayName, - getInstallStateLabel(state), + resource.publisher, resource.description, + statePresentation ? [statePresentation.summary, ...statePresentation.details].join('\n') : undefined, formatList(localize('marketplaceDetail.queries', "Try this"), resource.representativeQueries), localize('marketplaceDetail.typeAccessible', "Type: {0}", getMarketplaceTypeLabel(resource)), resource.publisher ? localize('marketplaceDetail.publisherAccessible', "Publisher: {0}", resource.publisher) : undefined, @@ -168,12 +191,27 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.locationFactEl = undefined; this.titleEl.textContent = resource.displayName; + this.publisherEl.textContent = resource.publisher ?? ''; + this.publisherEl.style.display = resource.publisher ? '' : 'none'; + const publisherUrl = getPublisherUrl(resource); + if (publisherUrl) { + this.publisherEl.href = publisherUrl.toString(true); + this.renderDisposables.add(DOM.addDisposableListener(this.publisherEl, DOM.EventType.CLICK, event => { + event.preventDefault(); + void this.options.openExternal(publisherUrl); + })); + } else { + this.publisherEl.removeAttribute('href'); + } + if (resource.publisher) { + this.renderDisposables.add(this.hoverService.setupDelayedHover(this.publisherEl, { content: resource.publisher })); + } this.renderIcon(); this.descriptionEl.textContent = resource.description || localize('marketplaceDetail.noDescription', "No description provided."); this.renderQueries(resource.representativeQueries); this.appendFact(localize('marketplaceDetail.type', "Type"), getMarketplaceTypeLabel(resource)); if (resource.publisher) { - this.appendFact(localize('marketplaceDetail.publisher', "Publisher"), resource.publisher); + this.appendFact(localize('marketplaceDetail.publisher', "Publisher"), this.createLink(resource.publisher, publisherUrl)); } if (resource.version) { this.appendFact(localize('marketplaceDetail.version', "Version"), resource.version); @@ -201,8 +239,7 @@ export class EmbeddedMarketplaceDetail extends Disposable { this.locationFactEl = undefined; const state = this.installService.getInstallState(resource); - this.stateEl.textContent = getInstallStateLabel(state); - this.stateEl.classList.toggle('unavailable', state.kind === 'unavailable'); + this.renderInstallStateBanner(state); this.renderInstallAction(resource, state); const location = getLocationTarget(state); @@ -214,6 +251,24 @@ export class EmbeddedMarketplaceDetail extends Disposable { } } + private renderInstallStateBanner(state: CustomizationMarketplaceInstallState): void { + const presentation = getInstallStatePresentation(state); + this.installStateEl.style.display = presentation ? '' : 'none'; + if (!presentation) { + return; + } + + this.installStateCardEl.className = `mcp-detail-diagnostic-card ${presentation.kind}`; + this.installStateIconEl.className = 'mcp-detail-diagnostic-icon'; + this.installStateIconEl.classList.add(...ThemeIcon.asClassNameArray(presentation.icon)); + this.installStateSummaryEl.textContent = presentation.summary; + DOM.clearNode(this.installStateDetailsEl); + this.installStateDetailsEl.style.display = presentation.details.length ? '' : 'none'; + for (const message of presentation.details) { + DOM.append(this.installStateDetailsEl, $('p.mcp-detail-diagnostic-message')).textContent = message; + } + } + private renderIcon(): void { const resource = this.current; this.iconDisposables.clear(); @@ -242,33 +297,116 @@ export class EmbeddedMarketplaceDetail extends Disposable { } private renderInstallAction(resource: ICustomizationMarketplaceResource, state: CustomizationMarketplaceInstallState): void { + if (resource.installation?.kind === 'copilotConnector') { + this.renderConnectorAction(resource, state); + return; + } const setupUrl = state.kind === 'unavailable' ? state.setupUrl : undefined; const label = state.kind === 'installed' ? localize('marketplaceDetail.installed', "Installed") : state.kind === 'installing' ? localize('marketplaceDetail.installing', "Installing...") - : state.kind === 'uninstalling' - ? localize('marketplaceDetail.uninstalling', "Uninstalling...") - : setupUrl - ? localize('marketplaceDetail.viewSetup', "View Setup") - : localize('marketplaceDetail.install', "Install"); + : state.kind === 'repairing' + ? localize('marketplaceDetail.repairing', "Repairing...") + : state.kind === 'missing' + ? localize('marketplaceDetail.repair', "Repair") + : state.kind === 'uninstalling' + ? localize('marketplaceDetail.uninstalling', "Uninstalling...") + : setupUrl + ? localize('marketplaceDetail.viewSetup', "View Setup") + : state.kind === 'unavailable' + ? localize('marketplaceDetail.unavailable', "Unavailable") + : localize('marketplaceDetail.install', "Install"); + const action = state.kind === 'missing' && !state.repairUnavailableMessage + ? () => this.installService.repair(resource) + : state.kind === 'available' + ? () => this.options.install(resource) + : setupUrl + ? () => this.options.openExternal(setupUrl) + : undefined; const button = this.installActionDisposables.add(new Button(this.titleActionsEl, { ...defaultButtonStyles, ariaLabel: localize('marketplaceDetail.actionAria', "{0} {1}", label, resource.displayName), })); button.label = label; - button.enabled = state.kind === 'available' || !!setupUrl; - button.element.setAttribute('aria-busy', String(state.kind === 'installing')); + button.enabled = action !== undefined; + button.element.setAttribute('aria-busy', String(state.kind === 'installing' || state.kind === 'repairing')); + if (!action) { + return; + } this.installActionDisposables.add(button.onDidClick(async () => { try { - if (setupUrl) { - await this.options.openExternal(setupUrl); - } else { - await this.options.install(resource); + await action(); + if (state.kind === 'available') { status(localize('marketplaceDetail.installedStatus', "Installed {0}.", resource.displayName)); + } else if (state.kind === 'missing') { + status(localize('marketplaceDetail.repairedStatus', "Repaired {0}.", resource.displayName)); + } + } catch (error) { + const message = state.kind === 'missing' + ? localize('marketplaceDetail.repairError', "Could not repair {0}. {1}", resource.displayName, getErrorMessage(error)) + : localize('marketplaceDetail.installError', "Could not install {0}. {1}", resource.displayName, getErrorMessage(error)); + this.notificationService.error(message); + } + })); + } + + private renderConnectorAction(resource: ICustomizationMarketplaceResource, state: CustomizationMarketplaceInstallState): void { + const setupUrl = state.kind === 'unavailable' ? state.setupUrl : undefined; + const label = state.kind === 'installed' + ? localize('marketplaceDetail.disconnect', "Disconnect") + : state.kind === 'missing' + ? localize('marketplaceDetail.reconnect', "Reconnect") + : state.kind === 'installing' + ? localize('marketplaceDetail.connecting', "Connecting...") + : state.kind === 'repairing' + ? localize('marketplaceDetail.reconnecting', "Reconnecting...") + : state.kind === 'uninstalling' + ? localize('marketplaceDetail.disconnecting', "Disconnecting...") + : state.kind === 'checking' + ? localize('marketplaceDetail.checkingConnection', "Checking...") + : setupUrl + ? localize('marketplaceDetail.viewSetup', "View Setup") + : state.kind === 'error' || state.kind === 'unavailable' + ? localize('marketplaceDetail.unavailable', "Unavailable") + : localize('marketplaceDetail.connect', "Connect"); + const action = state.kind === 'installed' + ? () => this.installService.uninstall(resource) + : state.kind === 'missing' && !state.repairUnavailableMessage + ? () => this.installService.repair(resource) + : state.kind === 'available' + ? () => this.options.install(resource) + : setupUrl + ? () => this.options.openExternal(setupUrl) + : undefined; + const button = this.installActionDisposables.add(new Button(this.titleActionsEl, { + ...defaultButtonStyles, + secondary: state.kind === 'installed', + ariaLabel: localize('marketplaceDetail.actionAria', "{0} {1}", label, resource.displayName), + })); + button.label = label; + button.enabled = action !== undefined; + button.element.setAttribute('aria-busy', String(state.kind === 'installing' || state.kind === 'checking' || state.kind === 'repairing' || state.kind === 'uninstalling')); + if (!action) { + return; + } + this.installActionDisposables.add(button.onDidClick(async () => { + try { + await action(); + if (state.kind === 'installed') { + status(localize('marketplaceDetail.disconnectedStatus', "Disconnected {0}.", resource.displayName)); + } else if (state.kind === 'missing') { + status(localize('marketplaceDetail.reconnectedStatus', "Reconnected {0}.", resource.displayName)); + } else if (state.kind === 'available') { + status(localize('marketplaceDetail.connectedStatus', "Connected {0}.", resource.displayName)); } } catch (error) { - this.notificationService.error(localize('marketplaceDetail.installError', "Could not install {0}. {1}", resource.displayName, getErrorMessage(error))); + const message = state.kind === 'installed' + ? localize('marketplaceDetail.disconnectError', "Could not disconnect {0}. {1}", resource.displayName, getErrorMessage(error)) + : state.kind === 'missing' + ? localize('marketplaceDetail.reconnectError', "Could not reconnect {0}. {1}", resource.displayName, getErrorMessage(error)) + : localize('marketplaceDetail.connectError', "Could not connect {0}. {1}", resource.displayName, getErrorMessage(error)); + this.notificationService.error(message); } })); } @@ -389,6 +527,45 @@ function getLocationTarget(state: CustomizationMarketplaceInstallState): URI | u } } +function getInstallStatePresentation(state: CustomizationMarketplaceInstallState): { readonly kind: 'warning' | 'error'; readonly icon: ThemeIcon; readonly summary: string; readonly details: readonly string[] } | undefined { + switch (state.kind) { + case 'missing': + return { + kind: 'warning', + icon: Codicon.warning, + summary: localize('marketplaceDetail.missingSummary', "This customization needs repair"), + details: state.repairUnavailableMessage ? [state.repairUnavailableMessage] : [], + }; + case 'error': + return { + kind: 'error', + icon: Codicon.error, + summary: localize('marketplaceDetail.errorSummary', "This customization has an error"), + details: [state.message], + }; + case 'unavailable': + return { + kind: 'warning', + icon: Codicon.warning, + summary: localize('marketplaceDetail.unavailableSummary', "This customization is unavailable"), + details: [state.message], + }; + default: + return undefined; + } +} + +function getPublisherUrl(resource: ICustomizationMarketplaceResource): URI | undefined { + if (resource.publisherUrl) { + return resource.publisherUrl; + } + if (resource.publisher && resource.repository?.scheme === Schemas.https && resource.repository.authority.toLowerCase() === 'github.com') { + const owner = resource.repository.path.split('/').filter(Boolean)[0]; + return owner ? URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${owner}` }) : undefined; + } + return undefined; +} + function getRepositoryLabel(repository: URI): string { const path = repository.path.replace(/^\/|\/$/g, ''); return path || repository.authority || repository.toString(); @@ -409,29 +586,6 @@ function getMarketplaceTypeLabel(resource: ICustomizationMarketplaceResource): s } } -function getInstallStateLabel(state: CustomizationMarketplaceInstallState): string { - switch (state.kind) { - case 'available': - return localize('marketplaceDetail.availableState', "Available to install"); - case 'installing': - return localize('marketplaceDetail.installingState', "Installation in progress"); - case 'checking': - return localize('marketplaceDetail.checkingState', "Checking installation"); - case 'installed': - return localize('marketplaceDetail.installedState', "Installed"); - case 'missing': - return localize('marketplaceDetail.missingState', "Installation needs repair"); - case 'repairing': - return localize('marketplaceDetail.repairingState', "Repair in progress"); - case 'uninstalling': - return localize('marketplaceDetail.uninstallingState', "Uninstall in progress"); - case 'error': - return localize('marketplaceDetail.errorState', "Installation error: {0}", state.message); - case 'unavailable': - return localize('marketplaceDetail.unavailableState', "Unavailable: {0}", state.message); - } -} - function formatList(label: string, values: readonly string[]): string | undefined { return values.length ? `${label}: ${values.join(', ')}` : undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 0c45fef78ce284..b174297d03760a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -1813,8 +1813,12 @@ per-word capitalization does not survive translation. */ margin: 0; } -.ai-customization-management-editor .embedded-marketplace-detail .embedded-detail-status-badge.unavailable { - color: var(--vscode-errorForeground); +.ai-customization-management-editor .marketplace-detail-install-state { + padding-inline-start: var(--vscode-spacing-size320); +} + +.ai-customization-management-editor .marketplace-detail-install-state .mcp-detail-diagnostic-card { + min-height: 0; } .ai-customization-management-editor .marketplace-detail-source-facts { @@ -2052,6 +2056,23 @@ per-word capitalization does not survive translation. */ margin-top: 1px; } +.ai-customization-management-editor .embedded-marketplace-detail .embedded-detail-publisher { + align-self: flex-end; + flex-shrink: 1; + min-width: 0; + max-width: 40%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label1); + font-weight: var(--vscode-fontWeight-regular); +} + +.ai-customization-management-editor .embedded-marketplace-detail .embedded-detail-publisher[href] { + color: var(--vscode-textLink-foreground); +} + .ai-customization-management-editor .ai-customization-embedded-detail .embedded-detail-title-actions .embedded-detail-disable-button.monaco-button { color: var(--vscode-descriptionForeground); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts index 2916fbea2ebabc..0cad2971fc84b7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginCustomizationMarketplaceProvider.ts @@ -4,13 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { URI } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { createLazyCustomizationMarketplaceProvider, CustomizationMarketplaceMediaType, ICustomizationMarketplaceProvider, ICustomizationMarketplaceSourceEntry, ICustomizationMarketplaceSourceInfo, ICustomizationMarketplaceSourcePage, ICustomizationMarketplaceSourceQuery } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; import { CustomizationMarketplaceConfiguration, CustomizationMarketplaceSources } from '../../../../../platform/customizationMarketplace/common/customizationMarketplaceSources.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ChatConfiguration } from '../../common/constants.js'; import { DEFAULT_PLUGIN_MARKETPLACE, parseMarketplaceReference } from '../../common/plugins/marketplaceReference.js'; -import { IMarketplacePlugin, IPluginMarketplaceService, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IPluginMarketplaceService, MarketplaceType, PluginSourceKind } from '../../common/plugins/pluginMarketplaceService.js'; const defaultMarketplaceId = parseMarketplaceReference(DEFAULT_PLUGIN_MARKETPLACE)!.canonicalId; const pluginMarketplaceSourceInfo: ICustomizationMarketplaceSourceInfo = { @@ -107,6 +109,7 @@ export class PluginCustomizationMarketplaceProvider implements ICustomizationMar } function toMarketplaceEntry(plugin: IMarketplacePlugin, registry: 'custom' | 'default', search: boolean): ICustomizationMarketplaceSourceEntry { + const publisher = getPluginPublisher(plugin); return { identifier: getPluginMarketplaceIdentifier(plugin), displayName: plugin.name, @@ -116,6 +119,8 @@ function toMarketplaceEntry(plugin: IMarketplacePlugin, registry: 'custom' | 'de capabilities: [], representativeQueries: [], originLabel: plugin.marketplace, + publisher: publisher?.name, + ...(publisher ? { publisherUrl: publisher.url } : {}), version: plugin.version, url: plugin.readmeUri, ...(plugin.readmeUri ? { readmeUri: plugin.readmeUri } : {}), @@ -125,6 +130,20 @@ function toMarketplaceEntry(plugin: IMarketplacePlugin, registry: 'custom' | 'de }; } +function getPluginPublisher(plugin: IMarketplacePlugin): { readonly name: string; readonly url: URI } | undefined { + let publisher: string | undefined; + if (plugin.sourceDescriptor.kind === PluginSourceKind.GitHub) { + publisher = plugin.sourceDescriptor.repo.split('/', 1)[0]; + } else if (plugin.sourceDescriptor.kind === PluginSourceKind.GitUrl) { + const match = plugin.sourceDescriptor.url.match(/^https:\/\/github\.com\/(?[^/]+)\//i); + if (match?.groups) { + publisher = match.groups.owner; + } + } + publisher ??= plugin.marketplaceReference.githubRepo?.split('/', 1)[0]; + return publisher ? { name: publisher, url: URI.from({ scheme: Schemas.https, authority: 'github.com', path: `/${publisher}` }) } : undefined; +} + function getPluginMarketplaceTypes(mediaType: string | undefined): ReadonlySet | undefined { if (!mediaType) { return allMarketplaceTypes; diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/copilotConnectorsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/copilotConnectorsService.test.ts index e387a1b61606f3..b71f395ed25f8d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/copilotConnectorsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/copilotConnectorsService.test.ts @@ -31,7 +31,11 @@ import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticati import { CopilotConnectorsMarketplaceProvider, CopilotConnectorsService } from '../../../browser/aiCustomization/copilotConnectorsService.js'; import { CustomizationMarketplaceMediaType } from '../../../../../../platform/customizationMarketplace/common/customizationMarketplaceService.js'; -function catalogResponse(status: 'available' | 'connected', names = ['mail']): unknown { +function catalogResponse( + status: 'available' | 'connected', + names = ['mail'], + publisherMetadata?: { readonly homepage?: string; readonly author?: { readonly name?: string; readonly url?: string } }, +): unknown { return { plugins: names.map(name => ({ name, @@ -43,6 +47,7 @@ function catalogResponse(status: 'available' | 'connected', names = ['mail']): u representativeQueries: [`Search ${name}`], iconUrl: `https://example.com/${name}.png`, documentationUrl: `https://example.com/${name}`, + ...publisherMetadata, }, connection: { status, @@ -206,20 +211,20 @@ suite('CopilotConnectorsService', () => { } test('validates catalog metadata and exposes an MCP marketplace source', async () => { - const fixture = createFixture([{ body: catalogResponse('available', ['mail', 'calendar']) }]); + const fixture = createFixture([{ body: catalogResponse('available', ['mail', 'calendar'], { homepage: 'https://github.com/features/copilot' }) }]); const source = new CopilotConnectorsMarketplaceProvider(fixture.service, fixture.configurationService); const first = await source.query({ query: 'connector', mediaType: CustomizationMarketplaceMediaType.McpServer, pageSize: 1 }, CancellationToken.None); const second = await source.query({ query: 'connector', mediaType: CustomizationMarketplaceMediaType.McpServer, pageSize: 1, cursor: first.nextCursor }, CancellationToken.None); assert.deepStrictEqual({ - first: first.items.map(item => ({ identifier: item.identifier, installation: item.installation, publisher: item.publisher })), + first: first.items.map(item => ({ identifier: item.identifier, installation: item.installation, publisher: item.publisher, publisherUrl: item.publisherUrl?.toString() })), second: second.items.map(item => item.identifier), total: first.total, hasCursor: typeof first.nextCursor === 'string', requests: fixture.requests.map(request => request.url), }, { - first: [{ identifier: 'mail', installation: { kind: 'copilotConnector', name: 'mail' }, publisher: 'GitHub Copilot' }], + first: [{ identifier: 'mail', installation: { kind: 'copilotConnector', name: 'mail' }, publisher: 'GitHub Copilot', publisherUrl: 'https://github.com/features/copilot' }], second: ['calendar'], total: 2, hasCursor: true, diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts index 1584b30d341717..e02c4150f63ca1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/embeddedMarketplaceDetail.test.ts @@ -17,21 +17,27 @@ import { INotificationService } from '../../../../../../platform/notification/co import { IRequestService } from '../../../../../../platform/request/common/request.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { EmbeddedMarketplaceDetail } from '../../../browser/aiCustomization/embeddedMarketplaceDetail.js'; -import { ICustomizationMarketplaceInstallService } from '../../../common/customizationMarketplaceInstallService.js'; +import { CustomizationMarketplaceInstallState, ICustomizationMarketplaceInstallService } from '../../../common/customizationMarketplaceInstallService.js'; suite('EmbeddedMarketplaceDetail', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function render(resource: ICustomizationMarketplaceResource, readmeContent?: string) { + function render(resource: ICustomizationMarketplaceResource, readmeContent?: string, installState: CustomizationMarketplaceInstallState = { kind: 'available' }) { const parent = DOM.append(document.body, DOM.$('.embedded-marketplace-detail-test')); store.add({ dispose: () => parent.remove() }); const instantiationService = workbenchInstantiationService(undefined, store); const installChangeEmitter = store.add(new Emitter()); let requestCount = 0; + let installCount = 0; + let repairCount = 0; + let uninstallCount = 0; + const openedExternal: Array = []; instantiationService.stub(INotificationService, new class extends mock() { }()); instantiationService.stub(ICustomizationMarketplaceInstallService, new class extends mock() { override readonly onDidChange = installChangeEmitter.event; - override getInstallState() { return { kind: 'available' as const }; } + override getInstallState() { return installState; } + override async repair() { repairCount++; } + override async uninstall() { uninstallCount++; } }()); instantiationService.stub(IRequestService, new class extends mock() { override async request(): Promise { @@ -44,8 +50,8 @@ suite('EmbeddedMarketplaceDetail', () => { }()); const detail = store.add(instantiationService.createInstance(EmbeddedMarketplaceDetail, parent, { getSourceLabel: () => 'Marketplace', - install: async () => { }, - openExternal: async () => { }, + install: async () => { installCount++; }, + openExternal: async resource => { openedExternal.push(resource); }, })); detail.setInput(resource); return { @@ -53,11 +59,13 @@ suite('EmbeddedMarketplaceDetail', () => { parent, fireInstallChange: () => installChangeEmitter.fire(), getRequestCount: () => requestCount, + getActionCounts: () => ({ installCount, repairCount, uninstallCount }), + getOpenedExternal: () => openedExternal.map(resource => typeof resource === 'string' ? resource : resource.toString()), }; } - test('renders ordered metadata and representative queries', () => { - const { detail, parent } = render({ + test('renders ordered metadata and representative queries', async () => { + const { detail, parent, getOpenedExternal } = render({ sourceId: 'test', identifier: 'review', displayName: 'Repository review', @@ -73,22 +81,30 @@ suite('EmbeddedMarketplaceDetail', () => { repository: URI.parse('https://github.com/example/review'), icon: URI.parse('https://example.com/review.svg'), }); + parent.querySelector('.embedded-detail-publisher')?.click(); + await timeout(0); assert.deepStrictEqual({ heading: parent.querySelector('h2')?.textContent, + publisher: parent.querySelector('.embedded-detail-publisher')?.textContent, + publisherHref: parent.querySelector('.embedded-detail-publisher')?.getAttribute('href'), icon: parent.querySelector('.marketplace-detail-icon img')?.getAttribute('src'), facts: [...parent.querySelectorAll('dt, dd')].map(element => element.textContent), queries: [...parent.querySelectorAll('.marketplace-detail-query-list li')].map(element => element.textContent), links: [...parent.querySelectorAll('.embedded-detail-fact-link')].map(element => element.textContent), actions: [...parent.querySelectorAll('.embedded-detail-title-actions .monaco-button')].map(element => element.textContent), + openedExternal: getOpenedExternal(), accessible: detail.getAccessibilityContent(), }, { heading: 'Repository review', + publisher: 'Example', + publisherHref: 'https://github.com/example', icon: 'https://example.com/review.svg', facts: ['Type', 'Skill', 'Publisher', 'Example', 'Version', '1.2.0', 'Source', 'Marketplace', 'Tags', 'review', 'Repository', 'example/review'], queries: ['Review this change'], - links: ['Marketplace', 'example/review'], + links: ['Example', 'Marketplace', 'example/review'], actions: ['Install'], - accessible: 'Repository review\n\nAvailable to install\n\nReviews pull requests.\n\nTry this: Review this change\n\nType: Skill\n\nPublisher: Example\n\nVersion: 1.2.0\n\nSource: Marketplace\n\nTags: review\n\nRepository: example/review', + openedExternal: ['https://github.com/example'], + accessible: 'Repository review\n\nExample\n\nReviews pull requests.\n\nTry this: Review this change\n\nType: Skill\n\nPublisher: Example\n\nVersion: 1.2.0\n\nSource: Marketplace\n\nTags: review\n\nRepository: example/review', }); }); @@ -106,13 +122,114 @@ suite('EmbeddedMarketplaceDetail', () => { assert.deepStrictEqual({ facts: [...parent.querySelectorAll('dt, dd')].map(element => element.textContent), queries: parent.querySelectorAll('.marketplace-detail-query-list li').length, + publisherDisplay: parent.querySelector('.embedded-detail-publisher')?.style.display, actions: [...parent.querySelectorAll('.embedded-detail-title-actions .monaco-button')].map(element => element.textContent), accessible: detail.getAccessibilityContent(), }, { facts: ['Type', 'Skill', 'Source', 'Marketplace'], queries: 0, + publisherDisplay: 'none', actions: ['Install'], - accessible: 'Project notes\n\nAvailable to install\n\nType: Skill\n\nSource: Marketplace', + accessible: 'Project notes\n\nType: Skill\n\nSource: Marketplace', + }); + }); + + test('uses connection actions for Copilot connectors', async () => { + const resource: ICustomizationMarketplaceResource = { + sourceId: 'connectors', + identifier: 'mail', + displayName: 'Mail', + description: 'Search mail.', + mediaType: CustomizationMarketplaceMediaType.McpServer, + publisher: 'GitHub Copilot', + tags: [], + capabilities: [], + representativeQueries: [], + installation: { kind: 'copilotConnector', name: 'mail' }, + }; + const available = render(resource); + const installed = render(resource, undefined, { kind: 'installed', target: { kind: 'copilotConnector', name: 'mail' } }); + + available.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.click(); + installed.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.click(); + await timeout(0); + + assert.deepStrictEqual({ + availableAction: available.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.textContent, + installedAction: installed.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.textContent, + availableCounts: available.getActionCounts(), + installedCounts: installed.getActionCounts(), + }, { + availableAction: 'Connect', + installedAction: 'Disconnect', + availableCounts: { installCount: 1, repairCount: 0, uninstallCount: 0 }, + installedCounts: { installCount: 0, repairCount: 0, uninstallCount: 1 }, + }); + }); + + test('renders failure states and repairs missing customizations', async () => { + const resource: ICustomizationMarketplaceResource = { + sourceId: 'test', + identifier: 'review', + displayName: 'Repository review', + description: 'Reviews pull requests.', + mediaType: CustomizationMarketplaceMediaType.Skill, + tags: [], + capabilities: [], + representativeQueries: [], + }; + const target = { kind: 'skill' as const, uri: URI.file('C:\\skills\\review') }; + const missing = render(resource, undefined, { kind: 'missing', target }); + const blockedMissing = render(resource, undefined, { kind: 'missing', target, repairUnavailableMessage: 'The source is no longer available.' }); + const error = render(resource, undefined, { kind: 'error', target, message: 'Installation failed.' }); + const unavailable = render(resource, undefined, { kind: 'unavailable', message: 'This item requires a newer version.' }); + + missing.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.click(); + await timeout(0); + + const state = (result: ReturnType) => ({ + banner: result.parent.querySelector('.mcp-detail-diagnostic-card')?.textContent, + bannerClass: result.parent.querySelector('.mcp-detail-diagnostic-card')?.className, + action: result.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.textContent, + actionDisabled: result.parent.querySelector('.embedded-detail-title-actions .monaco-button')?.classList.contains('disabled'), + accessible: result.detail.getAccessibilityContent(), + }); + assert.deepStrictEqual({ + missing: state(missing), + missingCounts: missing.getActionCounts(), + blockedMissing: state(blockedMissing), + error: state(error), + unavailable: state(unavailable), + }, { + missing: { + banner: 'This customization needs repair', + bannerClass: 'mcp-detail-diagnostic-card warning', + action: 'Repair', + actionDisabled: false, + accessible: 'Repository review\n\nReviews pull requests.\n\nThis customization needs repair\n\nType: Skill\n\nSource: Marketplace\n\nLocation: c:\\skills\\review', + }, + missingCounts: { installCount: 0, repairCount: 1, uninstallCount: 0 }, + blockedMissing: { + banner: 'This customization needs repairThe source is no longer available.', + bannerClass: 'mcp-detail-diagnostic-card warning', + action: 'Repair', + actionDisabled: true, + accessible: 'Repository review\n\nReviews pull requests.\n\nThis customization needs repair\nThe source is no longer available.\n\nType: Skill\n\nSource: Marketplace\n\nLocation: c:\\skills\\review', + }, + error: { + banner: 'This customization has an errorInstallation failed.', + bannerClass: 'mcp-detail-diagnostic-card error', + action: 'Install', + actionDisabled: true, + accessible: 'Repository review\n\nReviews pull requests.\n\nThis customization has an error\nInstallation failed.\n\nType: Skill\n\nSource: Marketplace\n\nLocation: c:\\skills\\review', + }, + unavailable: { + banner: 'This customization is unavailableThis item requires a newer version.', + bannerClass: 'mcp-detail-diagnostic-card warning', + action: 'Unavailable', + actionDisabled: true, + accessible: 'Repository review\n\nReviews pull requests.\n\nThis customization is unavailable\nThis item requires a newer version.\n\nType: Skill\n\nSource: Marketplace', + }, }); }); @@ -142,7 +259,7 @@ suite('EmbeddedMarketplaceDetail', () => { }, { contains: undefined, readme: 'Frontend Design\nUse the design system.', - accessible: 'Frontend Design\n\nAvailable to install\n\nDesign UI.\n\nType: Plugin\n\nSource: Marketplace\n\nPlugin README:\n# Frontend Design\n\nUse the design system.', + accessible: 'Frontend Design\n\nDesign UI.\n\nType: Plugin\n\nSource: Marketplace\n\nPlugin README:\n# Frontend Design\n\nUse the design system.', requestCount: 1, }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts index 9b318faad1e0e1..4c29708ddadf44 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/pluginCustomizationMarketplaceProvider.test.ts @@ -84,6 +84,8 @@ suite('PluginCustomizationMarketplaceProvider', () => { capabilities: [], representativeQueries: [], originLabel: customReference.displayLabel, + publisher: 'microsoft', + publisherUrl: URI.parse('https://github.com/microsoft'), version: '1.0', url: URI.parse('https://raw.githubusercontent.com/microsoft/plugins/stable/plugins/review/README.md'), readmeUri: URI.parse('https://raw.githubusercontent.com/microsoft/plugins/stable/plugins/review/README.md'), @@ -104,6 +106,44 @@ suite('PluginCustomizationMarketplaceProvider', () => { }); }); + test('uses a GitHub plugin source owner as the publisher', async () => { + const service = new TestPluginMarketplaceService(); + service.page = { + items: [{ + ...plugin, + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'octo-org/review-plugin' }, + }], + errors: [], + }; + const provider = new PluginCustomizationMarketplaceProvider('custom', service, new TestConfigurationService()); + + const page = await provider.query({}, CancellationToken.None); + + assert.deepStrictEqual({ publisher: page.items[0].publisher, publisherUrl: page.items[0].publisherUrl?.toString() }, { + publisher: 'octo-org', + publisherUrl: 'https://github.com/octo-org', + }); + }); + + test('uses a GitHub URL plugin source owner as the publisher', async () => { + const service = new TestPluginMarketplaceService(); + service.page = { + items: [{ + ...plugin, + sourceDescriptor: { kind: PluginSourceKind.GitUrl, url: 'https://github.com/acme/monorepo.git', path: 'plugins/review' }, + }], + errors: [], + }; + const provider = new PluginCustomizationMarketplaceProvider('custom', service, new TestConfigurationService()); + + const page = await provider.query({}, CancellationToken.None); + + assert.deepStrictEqual({ publisher: page.items[0].publisher, publisherUrl: page.items[0].publisherUrl?.toString() }, { + publisher: 'acme', + publisherUrl: 'https://github.com/acme', + }); + }); + test('default provider is suppressed while the public feed is enabled', async () => { const service = new TestPluginMarketplaceService(); const provider = new PluginCustomizationMarketplaceProvider('default', service, new TestConfigurationService({ diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 5ba76f89efabf7..369d0e652a931e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -996,6 +996,7 @@ interface IRenderEditorOptions { readonly toggleMarketplaceVisibility?: boolean; readonly customizationMarketplaceState?: 'ready' | 'empty' | 'error' | 'loading' | 'loadingMore'; readonly customizationMarketplaceInstallationState?: 'mixed' | 'missing' | 'error'; + readonly customizationMarketplaceDetailState?: 'missing' | 'error' | 'unavailable'; readonly discoveryQuery?: string; readonly clearDiscoveryQuery?: boolean; readonly selectDiscoveryResult?: boolean; @@ -1129,6 +1130,16 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } else if (options.customizationMarketplaceInstallationState === 'missing') { customizationMarketplaceInstallStates.set(getCustomizationMarketplaceResourceKey(customizationMarketplaceResources[0]), { kind: 'missing', target: getFixtureInstallationTarget(customizationMarketplaceResources[0]) }); } + if (options.customizationMarketplaceDetailState) { + const resource = customizationMarketplaceResources[0]; + const target = getFixtureInstallationTarget(resource); + const state: CustomizationMarketplaceInstallState = options.customizationMarketplaceDetailState === 'missing' + ? { kind: 'missing', target } + : options.customizationMarketplaceDetailState === 'error' + ? { kind: 'error', target, message: 'Installation failed because the selected destination is not writable.' } + : { kind: 'unavailable', message: 'This customization requires a runtime that is not installed.', setupUrl: URI.parse('https://example.com/setup') }; + customizationMarketplaceInstallStates.set(getCustomizationMarketplaceResourceKey(resource), state); + } const getCustomizationMarketplaceInstallations = () => createCustomizationMarketplaceInstallationSnapshot( customizationMarketplaceResources.flatMap(resource => { @@ -1920,6 +1931,18 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor } } assert(detailContainer !== null && scrollHost !== null, 'Marketplace detail must render inside its page scroll host.'); + if (options.customizationMarketplaceDetailState) { + const diagnostic = detailContainer.querySelector('.mcp-detail-diagnostic-card'); + assert(Boolean(diagnostic?.textContent?.trim()), 'Marketplace failure detail must render its diagnostic banner.'); + const detailSection = detailContainer.querySelector('.marketplace-detail-query-list'); + assert( + !diagnostic || !detailSection + || Math.abs(diagnostic.getBoundingClientRect().left - detailSection.getBoundingClientRect().left) <= 1 + && Math.abs(diagnostic.getBoundingClientRect().right - detailSection.getBoundingClientRect().right) <= 1, + 'Marketplace failure detail must align with the page content.', + ); + assert(options.customizationMarketplaceDetailState !== 'missing' || !diagnostic || diagnostic.offsetHeight < 72, 'A summary-only marketplace diagnostic must size to its content.'); + } assert( scrollHost.getBoundingClientRect().bottom <= detailContainer.getBoundingClientRect().bottom + 1, 'Marketplace detail scroll host must remain inside the detail page bounds.', @@ -3430,6 +3453,42 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), }), + DiscoverMissingDetail: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['A missing marketplace customization shows the MCP-style warning banner and a primary Repair action above its details.'], + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + marketplaceVisibilityEnabled: true, + discoveryQuery: 'repository review', + selectDiscoveryResult: true, + customizationMarketplaceDetailState: 'missing', + }), + }), + + DiscoverInstallErrorDetail: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['A marketplace installation error shows the MCP-style error banner with its failure reason and a disabled Install action above its details.'], + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + marketplaceVisibilityEnabled: true, + discoveryQuery: 'repository review', + selectDiscoveryResult: true, + customizationMarketplaceDetailState: 'error', + }), + }), + + DiscoverUnavailableDetail: defineComponentFixture({ + labels: { kind: 'screenshot', blocksCi: false }, + expectedVisualDescriptions: ['An unavailable marketplace customization shows the MCP-style warning banner with its setup reason and a View Setup action above its details.'], + render: ctx => renderEditor(ctx, { + sessionResource: localSessionResource, + marketplaceVisibilityEnabled: true, + discoveryQuery: 'repository review', + selectDiscoveryResult: true, + customizationMarketplaceDetailState: 'unavailable', + }), + }), + DiscoverMcpDetail: defineComponentFixture({ labels: { kind: 'screenshot', blocksCi: false }, expectedVisualDescriptions: ['An MCP marketplace item without a safely resolved workbench server uses the generic detail presentation and preserves its setup/install state and metadata.'],