diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index e33336bb08e..617a3971e05 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -14,6 +14,7 @@ import { ColorExtensionPoint } from 'vs/workbench/services/themes/common/colorEx import { IconExtensionPoint } from 'vs/workbench/services/themes/common/iconExtensionPoint'; import { TokenClassificationExtensionPoints } from 'vs/workbench/services/themes/common/tokenClassificationExtensionPoint'; import { LanguageConfigurationFileHandler } from 'vs/workbench/contrib/codeEditor/browser/languageConfigurationExtensionPoint'; +import { StatusBarItemsExtensionPoint } from 'vs/workbench/api/browser/statusBarExtensionPoint'; // --- mainThread participants import './mainThreadLocalization'; @@ -96,6 +97,7 @@ export class ExtensionPoints implements IWorkbenchContribution { this.instantiationService.createInstance(IconExtensionPoint); this.instantiationService.createInstance(TokenClassificationExtensionPoints); this.instantiationService.createInstance(LanguageConfigurationFileHandler); + this.instantiationService.createInstance(StatusBarItemsExtensionPoint); } } diff --git a/src/vs/workbench/api/browser/mainThreadStatusBar.ts b/src/vs/workbench/api/browser/mainThreadStatusBar.ts index 967fd986825..d3fa72190c3 100644 --- a/src/vs/workbench/api/browser/mainThreadStatusBar.ts +++ b/src/vs/workbench/api/browser/mainThreadStatusBar.ts @@ -3,90 +3,35 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IStatusbarService, StatusbarAlignment as MainThreadStatusBarAlignment, IStatusbarEntryAccessor, IStatusbarEntry, StatusbarAlignment, IStatusbarEntryPriority } from 'vs/workbench/services/statusbar/browser/statusbar'; import { MainThreadStatusBarShape, MainContext } from '../common/extHost.protocol'; import { ThemeColor } from 'vs/base/common/themables'; import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers'; -import { dispose } from 'vs/base/common/lifecycle'; +import { DisposableMap } from 'vs/base/common/lifecycle'; import { Command } from 'vs/editor/common/languages'; import { IAccessibilityInformation } from 'vs/platform/accessibility/common/accessibility'; import { IMarkdownString } from 'vs/base/common/htmlContent'; -import { getCodiconAriaLabel } from 'vs/base/common/iconLabels'; -import { hash } from 'vs/base/common/hash'; +import { IExtensionStatusBarItemService } from 'vs/workbench/api/browser/statusBarExtensionPoint'; @extHostNamedCustomer(MainContext.MainThreadStatusBar) export class MainThreadStatusBar implements MainThreadStatusBarShape { - private readonly entries: Map = new Map(); + private readonly entries = new DisposableMap(); constructor( _extHostContext: IExtHostContext, - @IStatusbarService private readonly statusbarService: IStatusbarService + @IExtensionStatusBarItemService private readonly statusbarService: IExtensionStatusBarItemService ) { } dispose(): void { - this.entries.forEach(entry => entry.accessor.dispose()); - this.entries.clear(); + this.entries.dispose(); } - $setEntry(entryId: number, id: string, extensionId: string | undefined, name: string, text: string, tooltip: IMarkdownString | string | undefined, command: Command | undefined, color: string | ThemeColor | undefined, backgroundColor: string | ThemeColor | undefined, alignLeft: boolean, priority: number | undefined, accessibilityInformation: IAccessibilityInformation): void { - // if there are icons in the text use the tooltip for the aria label - let ariaLabel: string; - let role: string | undefined = undefined; - if (accessibilityInformation) { - ariaLabel = accessibilityInformation.label; - role = accessibilityInformation.role; - } else { - ariaLabel = getCodiconAriaLabel(text); - if (tooltip) { - const tooltipString = typeof tooltip === 'string' ? tooltip : tooltip.value; - ariaLabel += `, ${tooltipString}`; - } - } - const entry: IStatusbarEntry = { name, text, tooltip, command, color, backgroundColor, ariaLabel, role }; - - if (typeof priority === 'undefined') { - priority = 0; - } - - const alignment = alignLeft ? StatusbarAlignment.LEFT : StatusbarAlignment.RIGHT; - - // Reset existing entry if alignment or priority changed - let existingEntry = this.entries.get(entryId); - if (existingEntry && (existingEntry.alignment !== alignment || existingEntry.priority !== priority)) { - dispose(existingEntry.accessor); - this.entries.delete(entryId); - existingEntry = undefined; - } - - // Create new entry if not existing - if (!existingEntry) { - let entryPriority: number | IStatusbarEntryPriority; - if (typeof extensionId === 'string') { - // We cannot enforce unique priorities across all extensions, so we - // use the extension identifier as a secondary sort key to reduce - // the likelyhood of collisions. - // See https://github.com/microsoft/vscode/issues/177835 - // See https://github.com/microsoft/vscode/issues/123827 - entryPriority = { primary: priority, secondary: hash(extensionId) }; - } else { - entryPriority = priority; - } - - this.entries.set(entryId, { accessor: this.statusbarService.addEntry(entry, id, alignment, entryPriority), alignment, priority }); - } - - // Otherwise update - else { - existingEntry.accessor.update(entry); - } + $setEntry(entryId: string, id: string, extensionId: string | undefined, name: string, text: string, tooltip: IMarkdownString | string | undefined, command: Command | undefined, color: string | ThemeColor | undefined, backgroundColor: string | ThemeColor | undefined, alignLeft: boolean, priority: number | undefined, accessibilityInformation: IAccessibilityInformation | undefined): void { + const dispo = this.statusbarService.setOrUpdateEntry(entryId, id, extensionId, name, text, tooltip, command, color, backgroundColor, alignLeft, priority, accessibilityInformation); + this.entries.set(entryId, dispo); } - $dispose(id: number) { - const entry = this.entries.get(id); - if (entry) { - dispose(entry.accessor); - this.entries.delete(id); - } + $dispose(entryId: string) { + this.entries.deleteAndDispose(entryId); } } diff --git a/src/vs/workbench/api/browser/statusBarExtensionPoint.ts b/src/vs/workbench/api/browser/statusBarExtensionPoint.ts new file mode 100644 index 00000000000..252cb31943d --- /dev/null +++ b/src/vs/workbench/api/browser/statusBarExtensionPoint.ts @@ -0,0 +1,222 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IJSONSchema } from 'vs/base/common/jsonSchema'; +import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { localize } from 'vs/nls'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; +import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; +import { IStatusbarService, StatusbarAlignment as MainThreadStatusBarAlignment, IStatusbarEntryAccessor, IStatusbarEntry, StatusbarAlignment, IStatusbarEntryPriority } from 'vs/workbench/services/statusbar/browser/statusbar'; +import { ThemeColor } from 'vs/base/common/themables'; +import { Command } from 'vs/editor/common/languages'; +import { IAccessibilityInformation } from 'vs/platform/accessibility/common/accessibility'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { getCodiconAriaLabel } from 'vs/base/common/iconLabels'; +import { hash } from 'vs/base/common/hash'; +import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; +import { Iterable } from 'vs/base/common/iterator'; +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; + + +// --- service + +export const IExtensionStatusBarItemService = createDecorator('IExtensionStatusBarItemService'); + +export interface IExtensionStatusBarItemService { + readonly _serviceBrand: undefined; + + setOrUpdateEntry(id: string, statusId: string, extensionId: string | undefined, name: string, text: string, tooltip: IMarkdownString | string | undefined, command: Command | undefined, color: string | ThemeColor | undefined, backgroundColor: string | ThemeColor | undefined, alignLeft: boolean, priority: number | undefined, accessibilityInformation: IAccessibilityInformation | undefined): IDisposable; +} + + +class ExtensionStatusBarItemService implements IExtensionStatusBarItemService { + + declare readonly _serviceBrand: undefined; + + private readonly entries: Map = new Map(); + + constructor( + @IStatusbarService private readonly _statusbarService: IStatusbarService + ) { } + + setOrUpdateEntry(entryId: string, id: string, extensionId: string | undefined, name: string, text: string, tooltip: IMarkdownString | string | undefined, command: Command | undefined, color: string | ThemeColor | undefined, backgroundColor: string | ThemeColor | undefined, alignLeft: boolean, priority: number | undefined, accessibilityInformation: IAccessibilityInformation | undefined): IDisposable { + // if there are icons in the text use the tooltip for the aria label + let ariaLabel: string; + let role: string | undefined = undefined; + if (accessibilityInformation) { + ariaLabel = accessibilityInformation.label; + role = accessibilityInformation.role; + } else { + ariaLabel = getCodiconAriaLabel(text); + if (tooltip) { + const tooltipString = typeof tooltip === 'string' ? tooltip : tooltip.value; + ariaLabel += `, ${tooltipString}`; + } + } + const entry: IStatusbarEntry = { name, text, tooltip, command, color, backgroundColor, ariaLabel, role }; + + if (typeof priority === 'undefined') { + priority = 0; + } + + let alignment = alignLeft ? StatusbarAlignment.LEFT : StatusbarAlignment.RIGHT; + + // alignment and priority can only be set once (at creation time) + const existingEntry = this.entries.get(entryId); + if (existingEntry) { + alignment = existingEntry.alignment; + priority = existingEntry.priority; + } + + // Create new entry if not existing + if (!existingEntry) { + let entryPriority: number | IStatusbarEntryPriority; + if (typeof extensionId === 'string') { + // We cannot enforce unique priorities across all extensions, so we + // use the extension identifier as a secondary sort key to reduce + // the likelyhood of collisions. + // See https://github.com/microsoft/vscode/issues/177835 + // See https://github.com/microsoft/vscode/issues/123827 + entryPriority = { primary: priority, secondary: hash(extensionId) }; + } else { + entryPriority = priority; + } + + this.entries.set(entryId, { + accessor: this._statusbarService.addEntry(entry, id, alignment, entryPriority), + alignment, + priority + }); + + } else { + // Otherwise update + existingEntry.accessor.update(entry); + } + + return toDisposable(() => { + const entry = this.entries.get(entryId); + if (entry) { + entry.accessor.dispose(); + this.entries.delete(entryId); + } + }); + } +} + +registerSingleton(IExtensionStatusBarItemService, ExtensionStatusBarItemService, InstantiationType.Delayed); + +// --- extension point and reading of it + +interface IUserFriendlyStatusItemEntry { + id: string; + name: string; + text: string; + alignment: 'left' | 'right'; + command?: string; + priority?: number; +} + +function isUserFriendlyStatusItemEntry(obj: any): obj is IUserFriendlyStatusItemEntry { + return (typeof obj.id === 'string' && obj.id.length > 0) + && typeof obj.name === 'string' + && typeof obj.text === 'string' + && (obj.alignment === 'left' || obj.alignment === 'right') + && (obj.command === undefined || typeof obj.command === 'string') + && (obj.priority === undefined || typeof obj.priority === 'number'); +} + +const statusBarItemSchema: IJSONSchema = { + type: 'object', + required: ['id', 'text', 'alignment', 'name'], + properties: { + id: { + type: 'string', + description: localize('id', 'The unique identifier of the status bar entry.') + }, + name: { + type: 'string', + description: localize('name', 'The name of the status bar entry.') + }, + text: { + type: 'string', + description: localize('text', 'The text to display in the status bar entry.') + }, + command: { + type: 'string', + description: localize('command', 'The command to execute when the status bar entry is clicked.') + }, + alignment: { + type: 'string', + enum: ['left', 'right'], + description: localize('alignment', 'The alignment of the status bar entry.') + }, + priority: { + type: 'number', + description: localize('priority', 'The priority of the status bar entry.') + } + } +}; + +const statusBarItemsSchema: IJSONSchema = { + description: localize('vscode.extension.contributes.statusBarItems', "Contributes items to the status bar."), + oneOf: [ + statusBarItemSchema, + { + type: 'array', + items: statusBarItemSchema + } + ] +}; + +const statusBarItemsExtensionPoint = ExtensionsRegistry.registerExtensionPoint({ + extensionPoint: 'statusBarItems', + jsonSchema: statusBarItemsSchema, +}); + +export class StatusBarItemsExtensionPoint { + + constructor(@IExtensionStatusBarItemService statusBarItemsService: IExtensionStatusBarItemService) { + + const contributions = new DisposableStore(); + + statusBarItemsExtensionPoint.setHandler((extensions) => { + + contributions.clear(); + + for (const entry of extensions) { + + if (!isProposedApiEnabled(entry.description, 'contribStatusBarItems')) { + entry.collector.error(`The ${statusBarItemsExtensionPoint.name} is proposed API`); + continue; + } + + const { value, collector } = entry; + + for (const candidate of Iterable.wrap(value)) { + if (!isUserFriendlyStatusItemEntry(candidate)) { + collector.error(localize('invalid', "Invalid status bar item contribution.")); + continue; + } + + const extensionId = ExtensionIdentifier.toKey(entry.description.identifier); + const fullItemId = `${extensionId}.${candidate.id}`; + + contributions.add(statusBarItemsService.setOrUpdateEntry( + fullItemId, + fullItemId, + extensionId, + candidate.name ?? entry.description.displayName ?? entry.description.name, + candidate.text, + undefined, undefined, undefined, undefined, + candidate.alignment === 'left', + candidate.priority, + undefined + )); + } + } + }); + } +} diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index de05bdd41d0..9a77f215937 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -609,8 +609,8 @@ export interface MainThreadQuickOpenShape extends IDisposable { } export interface MainThreadStatusBarShape extends IDisposable { - $setEntry(id: number, statusId: string, extensionId: string | undefined, statusName: string, text: string, tooltip: IMarkdownString | string | undefined, command: ICommandDto | undefined, color: string | ThemeColor | undefined, backgroundColor: string | ThemeColor | undefined, alignLeft: boolean, priority: number | undefined, accessibilityInformation: IAccessibilityInformation | undefined): void; - $dispose(id: number): void; + $setEntry(id: string, statusId: string, extensionId: string | undefined, statusName: string, text: string, tooltip: IMarkdownString | string | undefined, command: ICommandDto | undefined, color: string | ThemeColor | undefined, backgroundColor: string | ThemeColor | undefined, alignLeft: boolean, priority: number | undefined, accessibilityInformation: IAccessibilityInformation | undefined): void; + $dispose(id: string): void; } export interface MainThreadStorageShape extends IDisposable { diff --git a/src/vs/workbench/api/common/extHostStatusBar.ts b/src/vs/workbench/api/common/extHostStatusBar.ts index 51dc0fe04f8..191d5bb618d 100644 --- a/src/vs/workbench/api/common/extHostStatusBar.ts +++ b/src/vs/workbench/api/common/extHostStatusBar.ts @@ -9,7 +9,7 @@ import { MainContext, MainThreadStatusBarShape, IMainContext, ICommandDto } from import { localize } from 'vs/nls'; import { CommandsConverter } from 'vs/workbench/api/common/extHostCommands'; import { DisposableStore } from 'vs/base/common/lifecycle'; -import { IExtensionDescription } from 'vs/platform/extensions/common/extensions'; +import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions'; import { MarkdownString } from 'vs/workbench/api/common/extHostTypeConverters'; import { isNumber } from 'vs/base/common/types'; @@ -27,7 +27,7 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { #proxy: MainThreadStatusBarShape; #commands: CommandsConverter; - private _entryId: number; + private readonly _entryId: string; private _extension?: IExtensionDescription; @@ -59,8 +59,9 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { this.#proxy = proxy; this.#commands = commands; - this._entryId = ExtHostStatusBarEntry.ID_GEN++; - + this._entryId = id && extension + ? `${ExtensionIdentifier.toKey(extension.identifier)}.${id}` + : String(ExtHostStatusBarEntry.ID_GEN++); this._extension = extension; this._id = id; diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index c18e798edf8..d5055feaba1 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -22,6 +22,7 @@ export const allApiProposals = Object.freeze({ contribNotebookStaticPreloads: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribNotebookStaticPreloads.d.ts', contribRemoteHelp: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribRemoteHelp.d.ts', contribShareMenu: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribShareMenu.d.ts', + contribStatusBarItems: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribStatusBarItems.d.ts', contribViewsRemote: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts', contribViewsWelcome: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribViewsWelcome.d.ts', customEditorMove: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorMove.d.ts', diff --git a/src/vscode-dts/vscode.proposed.contribStatusBarItems.d.ts b/src/vscode-dts/vscode.proposed.contribStatusBarItems.d.ts new file mode 100644 index 00000000000..2e5a578de7a --- /dev/null +++ b/src/vscode-dts/vscode.proposed.contribStatusBarItems.d.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// empty placeholder for status bar items contribution + +// https://github.com/microsoft/vscode/issues/167874 @jrieken