diff --git a/src/vs/workbench/contrib/update/browser/media/postUpdateWidget.css b/src/vs/workbench/contrib/update/browser/media/postUpdateWidget.css new file mode 100644 index 0000000000000..119744c695498 --- /dev/null +++ b/src/vs/workbench/contrib/update/browser/media/postUpdateWidget.css @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.post-update-widget { + display: flex; + flex-direction: column; + gap: 12px; + padding: 6px 6px; + min-width: 300px; + max-width: 550px; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-bodyFontSize-small); +} + +/* Header */ +.post-update-widget .header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.post-update-widget .header .title { + font-weight: 600; + font-size: var(--vscode-bodyFontSize); + color: var(--vscode-foreground); +} + +/* Button bar */ +.post-update-widget .button-bar { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; +} + +.post-update-widget .button-bar button { + padding: 4px 12px; + border-radius: var(--vscode-cornerRadius-small); + font-size: var(--vscode-bodyFontSize-small); + cursor: pointer; + white-space: nowrap; +} + +.post-update-widget .update-button-secondary { + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border: 1px solid var(--vscode-button-border, transparent); +} + +.post-update-widget .update-button-secondary:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} + +.post-update-widget .update-button-leading-secondary { + margin-right: auto; +} + +.post-update-widget .update-button-primary { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: 1px solid var(--vscode-button-border, transparent); +} + +.post-update-widget .update-button-primary:hover { + background-color: var(--vscode-button-hoverBackground); +} diff --git a/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts b/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts new file mode 100644 index 0000000000000..978b2a15b8f64 --- /dev/null +++ b/src/vs/workbench/contrib/update/browser/postUpdateWidget.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../base/common/actions.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { isWeb } from '../../../../base/common/platform.js'; +import { localize } from '../../../../nls.js'; +import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { asTextOrError, IRequestService } from '../../../../platform/request/common/request.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { ShowCurrentReleaseNotesActionId } from '../common/update.js'; +import { IParsedUpdateInfoInput, parseUpdateInfoInput } from '../common/updateInfoParser.js'; +import { getUpdateInfoUrl, isMajorMinorVersionChange } from '../common/updateUtils.js'; +import './media/postUpdateWidget.css'; + +const LAST_KNOWN_VERSION_KEY = 'postUpdateWidget/lastKnownVersion'; + +interface ILastKnownVersion { + readonly version: string; + readonly commit: string | undefined; + readonly timestamp: number; +} + +/** + * Displays post-update call-to-action widget after a version change is detected. + */ +export class PostUpdateWidgetContribution extends Disposable implements IWorkbenchContribution { + + constructor( + @ICommandService private readonly commandService: ICommandService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IHostService private readonly hostService: IHostService, + @IHoverService private readonly hoverService: IHoverService, + @ILayoutService private readonly layoutService: ILayoutService, + @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IOpenerService private readonly openerService: IOpenerService, + @IProductService private readonly productService: IProductService, + @IRequestService private readonly requestService: IRequestService, + @IStorageService private readonly storageService: IStorageService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + ) { + super(); + + if (isWeb) { + return; // Electron only + } + + this._register(CommandsRegistry.registerCommand('_update.showUpdateInfo', (_accessor, markdown?: string) => this.showUpdateInfo(markdown))); + void this.tryShowOnStartup(); + } + + private async tryShowOnStartup() { + if (!await this.hostService.hadLastFocus()) { + return; + } + + if (!this.detectVersionChange()) { + return; + } + + if (this.configurationService.getValue('update.showPostInstallInfo') === false) { + return; + } + + await this.showUpdateInfo(); + } + + private async showUpdateInfo(markdown?: string) { + const info = await this.getUpdateInfo(markdown); + if (!info) { + return; + } + + const contentDisposables = new DisposableStore(); + const target = this.layoutService.mainContainer; + const { clientWidth } = target; + const maxWidth = 550; + const x = Math.max(clientWidth - maxWidth - 80, 16); + + this.hoverService.showInstantHover({ + content: this.buildContent(info, contentDisposables), + target: { + targetElements: [target], + x, + y: 40, + dispose: () => contentDisposables.dispose() + }, + persistence: { sticky: true }, + appearance: { showPointer: false, compact: true, maxHeightRatio: 0.8 }, + }, true); + } + + private async getUpdateInfo(input?: string | null): Promise { + if (!input) { + try { + const url = getUpdateInfoUrl(this.productService.version); + const context = await this.requestService.request({ url, callSite: 'postUpdateWidget' }, CancellationToken.None); + input = await asTextOrError(context); + } catch { } + } + + if (!input) { + return undefined; + } + + let info = parseUpdateInfoInput(input); + if (!info?.buttons?.length) { + info = { + ...info, buttons: [{ + label: localize('postUpdate.releaseNotes', "Release Notes"), + commandId: ShowCurrentReleaseNotesActionId, + args: [this.productService.version], + style: 'secondary' + }] + }; + } + + return info; + } + + private buildContent({ markdown, buttons }: IParsedUpdateInfoInput, disposables: DisposableStore): HTMLElement { + const container = dom.$('.post-update-widget'); + + // Header + const header = dom.append(container, dom.$('.header')); + const title = dom.append(header, dom.$('.title')); + title.textContent = localize('postUpdate.title', "New in {0}", this.productService.version); + + // Markdown + const markdownContainer = dom.append(container, dom.$('.update-markdown')); + const rendered = disposables.add(this.markdownRendererService.render( + new MarkdownString(markdown, { + isTrusted: true, + supportHtml: true, + supportThemeIcons: true, + }), + { + actionHandler: (link, mdStr) => { + openLinkFromMarkdown(this.openerService, link, mdStr.isTrusted); + this.hoverService.hideHover(true); + }, + })); + markdownContainer.appendChild(rendered.element); + + // Buttons + if (buttons?.length) { + const buttonBar = dom.append(container, dom.$('.button-bar')); + let seenSecondary = false; + + for (const { label, style, commandId, args } of buttons) { + const button = dom.append(buttonBar, dom.$('button')) as HTMLButtonElement; + button.textContent = label; + + if (style === 'secondary') { + button.classList.add('update-button-secondary'); + if (!seenSecondary && buttons.length > 1) { + button.classList.add('update-button-leading-secondary'); + seenSecondary = true; + } + } else { + button.classList.add('update-button-primary'); + } + + disposables.add(dom.addDisposableListener(button, 'click', () => { + this.telemetryService.publicLog2( + 'workbenchActionExecuted', + { id: commandId, from: 'postUpdateWidget' } + ); + + void this.commandService.executeCommand(commandId, ...(args ?? [])); + this.hoverService.hideHover(true); + })); + } + } + + return container; + } + + private detectVersionChange(): boolean { + let from: ILastKnownVersion | undefined; + try { + from = this.storageService.getObject(LAST_KNOWN_VERSION_KEY, StorageScope.APPLICATION); + } catch { } + + const to: ILastKnownVersion = { + version: this.productService.version, + commit: this.productService.commit, + timestamp: Date.now(), + }; + + if (from?.commit === to.commit) { + return false; + } + + this.storageService.store(LAST_KNOWN_VERSION_KEY, JSON.stringify(to), StorageScope.APPLICATION, StorageTarget.MACHINE); + + if (from) { + return isMajorMinorVersionChange(from.version, to.version); + } + + return false; + } +} diff --git a/src/vs/workbench/contrib/update/browser/update.contribution.ts b/src/vs/workbench/contrib/update/browser/update.contribution.ts index 4d5801ef372ca..777207dae808a 100644 --- a/src/vs/workbench/contrib/update/browser/update.contribution.ts +++ b/src/vs/workbench/contrib/update/browser/update.contribution.ts @@ -13,6 +13,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { ProductContribution, UpdateContribution, CONTEXT_UPDATE_STATE, SwitchProductQualityContribution, showReleaseNotesInEditor, DefaultAccountUpdateContribution } from './update.js'; import { UpdateTitleBarContribution } from './updateTitleBarEntry.js'; +import { PostUpdateWidgetContribution } from './postUpdateWidget.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import product from '../../../../platform/product/common/product.js'; import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; @@ -33,6 +34,7 @@ workbench.registerWorkbenchContribution(UpdateContribution, LifecyclePhase.Resto workbench.registerWorkbenchContribution(SwitchProductQualityContribution, LifecyclePhase.Restored); workbench.registerWorkbenchContribution(DefaultAccountUpdateContribution, LifecyclePhase.Eventually); workbench.registerWorkbenchContribution(UpdateTitleBarContribution, LifecyclePhase.Restored); +workbench.registerWorkbenchContribution(PostUpdateWidgetContribution, LifecyclePhase.Restored); // Release notes @@ -258,7 +260,7 @@ registerAction2(class ShowUpdateInfoAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const commandService = accessor.get(ICommandService); const quickInputService = accessor.get(IQuickInputService); - const markdown = await quickInputService.input({ prompt: localize('showUpdateInfo.prompt', "Enter markdown to render (leave empty to load from URL)") }); + const markdown = await quickInputService.input({ prompt: localize('showUpdateInfo.prompt', "Enter markdown to render, or JSON with markdown/buttons (leave empty to load from URL)") }); if (markdown === undefined) { return; // cancelled } diff --git a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts index 09da755a08058..7466c6ae40bb4 100644 --- a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts @@ -13,19 +13,16 @@ import { isWeb } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { DisablementReason, IUpdateService, State, StateType } from '../../../../platform/update/common/update.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IChatService } from '../../chat/common/chatService/chatService.js'; -import { computeProgressPercent, isMajorMinorVersionChange } from '../common/updateUtils.js'; +import { computeProgressPercent } from '../common/updateUtils.js'; import { waitForState } from '../../../../base/common/observable.js'; import './media/updateTitleBarEntry.css'; import { UpdateTooltip } from './updateTooltip.js'; @@ -36,14 +33,6 @@ const UPDATE_TITLE_BAR_CONTEXT = new RawContextKey('updateTitleBar', fa const ACTIONABLE_STATES: readonly StateType[] = [StateType.AvailableForDownload, StateType.Downloaded, StateType.Ready]; const DETAILED_STATES: readonly StateType[] = [...ACTIONABLE_STATES, StateType.CheckingForUpdates, StateType.Downloading, StateType.Updating, StateType.Overwriting]; -const LAST_KNOWN_VERSION_KEY = 'updateTitleBarEntry/lastKnownVersion'; - -interface ILastKnownVersion { - readonly version: string; - readonly commit: string | undefined; - readonly timestamp: number; -} - registerAction2(class UpdateIndicatorTitleBarAction extends Action2 { constructor() { super({ @@ -75,12 +64,9 @@ export class UpdateTitleBarContribution extends Disposable implements IWorkbench constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @IChatService private readonly chatService: IChatService, - @IConfigurationService private readonly configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService private readonly hostService: IHostService, @IInstantiationService instantiationService: IInstantiationService, - @IProductService private readonly productService: IProductService, - @IStorageService private readonly storageService: IStorageService, @IUpdateService updateService: IUpdateService, ) { super(); @@ -115,20 +101,9 @@ export class UpdateTitleBarContribution extends Disposable implements IWorkbench } )); - this._register(CommandsRegistry.registerCommand('_update.showUpdateInfo', (_accessor, markdown?: string) => this.showUpdateInfo(markdown))); - void this.onStateChange(true); } - private async showUpdateInfo(markdown?: string) { - const rendered = await this.tooltip.renderPostInstall(markdown); - if (rendered) { - this.tooltipVisible = true; - this.context.set(true); - this.entry?.showTooltip(true); - } - } - private async onStateChange(startup = false) { this.pendingShow.clear(); if (ACTIONABLE_STATES.includes(this.state.type)) { @@ -142,30 +117,26 @@ export class UpdateTitleBarContribution extends Disposable implements IWorkbench return; } - let showTooltip = startup && this.detectVersionChange(); - if (showTooltip && this.configurationService.getValue('update.showPostInstallInfo') !== false) { - showTooltip = await this.tooltip.renderPostInstall(); - } else { - this.tooltip.renderState(this.state); - switch (this.state.type) { - case StateType.Disabled: - if (startup) { - const reason = this.state.reason; - showTooltip = reason === DisablementReason.InvalidConfiguration || reason === DisablementReason.RunningAsAdmin; - } - break; - case StateType.Idle: - showTooltip = !!this.state.error; - break; - case StateType.Downloading: - case StateType.Updating: - case StateType.Overwriting: - this.context.set(this.state.explicit); - break; - case StateType.Restarting: - this.context.set(true); - break; - } + this.tooltip.renderState(this.state); + let showTooltip = false; + switch (this.state.type) { + case StateType.Disabled: + if (startup) { + const reason = this.state.reason; + showTooltip = reason === DisablementReason.InvalidConfiguration || reason === DisablementReason.RunningAsAdmin; + } + break; + case StateType.Idle: + showTooltip = !!this.state.error; + break; + case StateType.Downloading: + case StateType.Updating: + case StateType.Overwriting: + this.context.set(this.state.explicit); + break; + case StateType.Restarting: + this.context.set(true); + break; } if (showTooltip) { @@ -191,30 +162,6 @@ export class UpdateTitleBarContribution extends Disposable implements IWorkbench } } - private detectVersionChange() { - let from: ILastKnownVersion | undefined; - try { - from = this.storageService.getObject(LAST_KNOWN_VERSION_KEY, StorageScope.APPLICATION); - } catch { } - - const to: ILastKnownVersion = { - version: this.productService.version, - commit: this.productService.commit, - timestamp: Date.now(), - }; - - if (from?.commit === to.commit) { - return false; - } - - this.storageService.store(LAST_KNOWN_VERSION_KEY, JSON.stringify(to), StorageScope.APPLICATION, StorageTarget.MACHINE); - - if (from) { - return isMajorMinorVersionChange(from.version, to.version); - } - - return false; - } } /** diff --git a/src/vs/workbench/contrib/update/browser/updateTooltip.ts b/src/vs/workbench/contrib/update/browser/updateTooltip.ts index b64a06871d4c2..85e34cca1a393 100644 --- a/src/vs/workbench/contrib/update/browser/updateTooltip.ts +++ b/src/vs/workbench/contrib/update/browser/updateTooltip.ts @@ -4,24 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js'; import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; -import { asTextOrError, IRequestService } from '../../../../platform/request/common/request.js'; import { AvailableForDownload, Disabled, DisablementReason, Downloaded, Downloading, Idle, IUpdate, Overwriting, Ready, Restarting, State, StateType, Updating } from '../../../../platform/update/common/update.js'; import { ShowCurrentReleaseNotesActionId } from '../common/update.js'; -import { computeDownloadSpeed, computeDownloadTimeRemaining, computeProgressPercent, formatBytes, formatDate, formatTimeRemaining, getUpdateInfoUrl, tryParseDate } from '../common/updateUtils.js'; +import { computeDownloadSpeed, computeDownloadTimeRemaining, computeProgressPercent, formatBytes, formatDate, formatTimeRemaining, tryParseDate } from '../common/updateUtils.js'; import './media/updateTooltip.css'; /** @@ -53,10 +48,6 @@ export class UpdateTooltip extends Disposable { private readonly timeRemainingNode: HTMLElement; private readonly speedInfoNode: HTMLElement; - // Update markdown section - private readonly markdownContainer: HTMLElement; - private readonly markdown = this._register(new MutableDisposable()); - // State-specific message private readonly messageNode: HTMLElement; @@ -72,11 +63,8 @@ export class UpdateTooltip extends Disposable { @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, @IHoverService private readonly hoverService: IHoverService, - @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService, - @IOpenerService private readonly openerService: IOpenerService, @IProductService private readonly productService: IProductService, - @IRequestService private readonly requestService: IRequestService, ) { super(); @@ -122,9 +110,6 @@ export class UpdateTooltip extends Disposable { this.timeRemainingNode = dom.append(this.downloadStatsContainer, dom.$('.time-remaining')); this.speedInfoNode = dom.append(this.downloadStatsContainer, dom.$('.speed-info')); - // Update markdown section - this.markdownContainer = dom.append(this.domNode, dom.$('.update-markdown')); - // State-specific message this.messageNode = dom.append(this.domNode, dom.$('.state-message')); @@ -171,8 +156,6 @@ export class UpdateTooltip extends Disposable { this.speedInfoNode.textContent = ''; this.timeRemainingNode.textContent = ''; this.messageNode.style.display = 'none'; - this.markdownContainer.style.display = 'none'; - this.markdown.clear(); this.actionButton.style.display = 'none'; this.actionButton.dataset.commandId = ''; this.releaseNotesButton.style.marginRight = ''; @@ -382,51 +365,6 @@ export class UpdateTooltip extends Disposable { this.renderMessage(localize('updateTooltip.restartingPleaseWait', "Restarting to update, please wait...")); } - public async renderPostInstall(markdown?: string): Promise { - this.hideAll(); - this.renderTitleAndInfo(localize('updateTooltip.installedDefaultTitle', "New Update Installed")); - this.renderMessage( - localize('updateTooltip.installedDefaultMessage', "See release notes for details on what's new in this release."), - Codicon.info); - - let text: string | null = markdown ?? null; - if (!text) { - try { - const url = getUpdateInfoUrl(this.productService.version); - const context = await this.requestService.request({ url, callSite: 'updateTooltip' }, CancellationToken.None); - text = await asTextOrError(context); - } catch { } - } - - if (!text) { - return false; - } - - this.titleNode.textContent = localize('updateTooltip.installedTitle', "New in {0}", this.productService.version); - this.productInfoNode.style.display = 'none'; - this.messageNode.style.display = 'none'; - - const rendered = this.markdownRendererService.render( - new MarkdownString(text, { - isTrusted: true, - supportHtml: true, - supportThemeIcons: true, - }), - { - actionHandler: (link, mdStr) => { - openLinkFromMarkdown(this.openerService, link, mdStr.isTrusted); - this.hoverService.hideHover(true); - }, - }); - - this.markdown.value = rendered; - dom.clearNode(this.markdownContainer); - this.markdownContainer.appendChild(rendered.element); - this.markdownContainer.style.display = ''; - - return true; - } - private renderTitleAndInfo(title: string, update?: IUpdate) { this.titleNode.textContent = title; diff --git a/src/vs/workbench/contrib/update/common/updateInfoParser.ts b/src/vs/workbench/contrib/update/common/updateInfoParser.ts new file mode 100644 index 0000000000000..e34a9a6901a44 --- /dev/null +++ b/src/vs/workbench/contrib/update/common/updateInfoParser.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { hasKey } from '../../../../base/common/types.js'; + +export type UpdateInfoButtonStyle = 'primary' | 'secondary'; + +export interface IUpdateInfoButton { + readonly label: string; + readonly commandId: string; + readonly args?: unknown[]; + readonly style?: UpdateInfoButtonStyle; +} + +export interface IParsedUpdateInfoInput { + readonly markdown: string; + readonly buttons?: IUpdateInfoButton[]; +} + +/** + * Parses optional metadata from update info input. + * + * Supported formats: + * + * **JSON envelope** - a single JSON object with `markdown` and optional `buttons`: + * ```json + * { + * "markdown": "$(info) **Feature**
Description...", + * "buttons": [ + * { "label": "Release Notes", "commandId": "update.showCurrentReleaseNotes", "style": "secondary" }, + * { "label": "Open Sessions", "commandId": "workbench.action.chat.open", "style": "primary" } + * ] + * } + * ``` + * + * **Block frontmatter** - YAML-style `---` delimiters wrapping a JSON metadata block: + * ``` + * --- + * { "buttons": [...] } + * --- + * $(info) **Feature**
Description... + * ``` + * + * **Inline frontmatter** - metadata on a single `---` line: + * ``` + * --- { "buttons": [...] } --- + * $(info) **Feature**
Description... + * ``` + */ +export function parseUpdateInfoInput(text: string): IParsedUpdateInfoInput { + const normalized = text.replace(/^\uFEFF/, ''); + return tryParseUpdateInfoEnvelope(normalized) ?? parseUpdateInfoFrontmatter(normalized); +} + +function tryParseUpdateInfoEnvelope(text: string): IParsedUpdateInfoInput | undefined { + const trimmed = text.trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return undefined; + } + + try { + const value = JSON.parse(trimmed) as { markdown?: string; buttons?: unknown }; + if (typeof value.markdown !== 'string') { + return undefined; + } + + return { + markdown: value.markdown, + buttons: parseUpdateInfoButtons(value.buttons), + }; + } catch { + return undefined; + } +} + +function parseUpdateInfoFrontmatter(text: string): IParsedUpdateInfoInput { + const blockMatch = text.match(/^---[ \t]*\r?\n(?[\s\S]*?)\r?\n---[ \t]*(?:\r?\n(?[\s\S]*))?$/); + if (blockMatch?.groups) { + return parseUpdateInfoFrontmatterMatch(text, blockMatch.groups['json'], blockMatch.groups['body'] ?? ''); + } + + const inlineMatch = text.match(/^---[ \t]*(?\{.*\})[ \t]*---[ \t]*(?[\s\S]*)$/); + if (inlineMatch?.groups) { + return parseUpdateInfoFrontmatterMatch(text, inlineMatch.groups['json'], inlineMatch.groups['body']); + } + + return { markdown: text }; +} + +function parseUpdateInfoFrontmatterMatch(text: string, jsonText: string, markdown: string): IParsedUpdateInfoInput { + try { + const meta = JSON.parse(jsonText) as { buttons?: unknown }; + return { + markdown, + buttons: parseUpdateInfoButtons(meta.buttons), + }; + } catch { + return { markdown: text }; + } +} + +function parseUpdateInfoButtons(buttons: unknown): IUpdateInfoButton[] | undefined { + if (!Array.isArray(buttons)) { + return undefined; + } + + const parsedButtons: IUpdateInfoButton[] = []; + for (const button of buttons) { + if (typeof button !== 'object' || button === null) { + continue; + } + + if (!hasKey(button, { label: true, commandId: true }) || typeof button.label !== 'string' || typeof button.commandId !== 'string') { + continue; + } + + const style = hasKey(button, { style: true }) && (button.style === 'primary' || button.style === 'secondary') ? button.style : undefined; + const args = hasKey(button, { args: true }) && Array.isArray(button.args) ? button.args : undefined; + parsedButtons.push({ + label: button.label, + commandId: button.commandId, + args, + style, + }); + } + + return parsedButtons.length ? parsedButtons : undefined; +} diff --git a/src/vs/workbench/contrib/update/test/common/updateInfoParser.test.ts b/src/vs/workbench/contrib/update/test/common/updateInfoParser.test.ts new file mode 100644 index 0000000000000..e60a530bdd53d --- /dev/null +++ b/src/vs/workbench/contrib/update/test/common/updateInfoParser.test.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { parseUpdateInfoInput } from '../../common/updateInfoParser.js'; + +suite('updateInfoParser', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('parseUpdateInfoInput', () => { + + test('plain markdown returns as-is with no buttons', () => { + assert.deepStrictEqual(parseUpdateInfoInput('Hello **world**'), { + markdown: 'Hello **world**', + }); + }); + + test('strips BOM prefix', () => { + assert.deepStrictEqual(parseUpdateInfoInput('\uFEFFHello'), { + markdown: 'Hello', + }); + }); + + test('JSON envelope with markdown and buttons', () => { + const input = JSON.stringify({ + markdown: '$(info) New feature', + buttons: [ + { label: 'Release Notes', commandId: 'cmd.releaseNotes', style: 'secondary' }, + { label: 'Try It', commandId: 'cmd.tryIt', style: 'primary', args: ['arg1'] }, + ], + }); + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: '$(info) New feature', + buttons: [ + { label: 'Release Notes', commandId: 'cmd.releaseNotes', style: 'secondary', args: undefined }, + { label: 'Try It', commandId: 'cmd.tryIt', style: 'primary', args: ['arg1'] }, + ], + }); + }); + + test('JSON envelope without buttons', () => { + const input = JSON.stringify({ markdown: 'Just text' }); + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'Just text', + buttons: undefined, + }); + }); + + test('JSON envelope with invalid JSON falls back to plain markdown', () => { + const input = '{ broken json'; + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: '{ broken json', + }); + }); + + test('JSON envelope without markdown property falls back to plain markdown', () => { + const input = JSON.stringify({ buttons: [{ label: 'X', commandId: 'y' }] }); + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: input, + }); + }); + + test('block frontmatter with buttons', () => { + const buttons = [{ label: 'Open', commandId: 'cmd.open' }]; + const input = `---\n${JSON.stringify({ buttons })}\n---\nBody text`; + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'Body text', + buttons: [{ label: 'Open', commandId: 'cmd.open', style: undefined, args: undefined }], + }); + }); + + test('block frontmatter with no body', () => { + const buttons = [{ label: 'Open', commandId: 'cmd.open' }]; + const input = `---\n${JSON.stringify({ buttons })}\n---`; + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: '', + buttons: [{ label: 'Open', commandId: 'cmd.open', style: undefined, args: undefined }], + }); + }); + + test('inline frontmatter with buttons', () => { + const buttons = [{ label: 'Go', commandId: 'cmd.go', style: 'primary' }]; + const input = `--- ${JSON.stringify({ buttons })} ---\nMarkdown here`; + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: '\nMarkdown here', + buttons: [{ label: 'Go', commandId: 'cmd.go', style: 'primary', args: undefined }], + }); + }); + + test('inline frontmatter handles nested JSON with braces', () => { + const buttons = [{ label: 'Open', commandId: 'cmd.open' }, { label: 'Try', commandId: 'cmd.try' }]; + const input = `--- ${JSON.stringify({ buttons })} ---\nBody`; + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: '\nBody', + buttons: [ + { label: 'Open', commandId: 'cmd.open', style: undefined, args: undefined }, + { label: 'Try', commandId: 'cmd.try', style: undefined, args: undefined }, + ], + }); + }); + + test('frontmatter with invalid JSON falls back to full text', () => { + const input = '---\nnot json\n---\nBody'; + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: input, + }); + }); + + test('skips buttons with missing required properties', () => { + const input = JSON.stringify({ + markdown: 'text', + buttons: [ + { label: 'Valid', commandId: 'cmd.valid' }, + { label: 'MissingCmd' }, + { commandId: 'cmd.missingLabel' }, + 'not an object', + null, + ], + }); + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'text', + buttons: [{ label: 'Valid', commandId: 'cmd.valid', style: undefined, args: undefined }], + }); + }); + + test('returns undefined buttons when all buttons are invalid', () => { + const input = JSON.stringify({ + markdown: 'text', + buttons: [{ noLabel: true }], + }); + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'text', + buttons: undefined, + }); + }); + + test('ignores invalid style values', () => { + const input = JSON.stringify({ + markdown: 'text', + buttons: [{ label: 'X', commandId: 'cmd', style: 'danger' }], + }); + + assert.deepStrictEqual(parseUpdateInfoInput(input), { + markdown: 'text', + buttons: [{ label: 'X', commandId: 'cmd', style: undefined, args: undefined }], + }); + }); + }); +});