|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * Copyright (c) Microsoft Corporation. All rights reserved. |
| 3 | + * Licensed under the MIT License. See License.txt in the project root for license information. |
| 4 | + *--------------------------------------------------------------------------------------------*/ |
| 5 | + |
| 6 | +import { IJSONSchema } from 'vs/base/common/jsonSchema'; |
| 7 | +import { DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; |
| 8 | +import { localize } from 'vs/nls'; |
| 9 | +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; |
| 10 | +import { isProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; |
| 11 | +import { ExtensionsRegistry } from 'vs/workbench/services/extensions/common/extensionsRegistry'; |
| 12 | +import { IStatusbarService, StatusbarAlignment as MainThreadStatusBarAlignment, IStatusbarEntryAccessor, IStatusbarEntry, StatusbarAlignment, IStatusbarEntryPriority } from 'vs/workbench/services/statusbar/browser/statusbar'; |
| 13 | +import { ThemeColor } from 'vs/base/common/themables'; |
| 14 | +import { Command } from 'vs/editor/common/languages'; |
| 15 | +import { IAccessibilityInformation } from 'vs/platform/accessibility/common/accessibility'; |
| 16 | +import { IMarkdownString } from 'vs/base/common/htmlContent'; |
| 17 | +import { getCodiconAriaLabel } from 'vs/base/common/iconLabels'; |
| 18 | +import { hash } from 'vs/base/common/hash'; |
| 19 | +import { InstantiationType, registerSingleton } from 'vs/platform/instantiation/common/extensions'; |
| 20 | +import { Iterable } from 'vs/base/common/iterator'; |
| 21 | +import { ExtensionIdentifier } from 'vs/platform/extensions/common/extensions'; |
| 22 | +import { asStatusBarItemIdentifier } from 'vs/workbench/api/common/extHostTypes'; |
| 23 | + |
| 24 | + |
| 25 | +// --- service |
| 26 | + |
| 27 | +export const IExtensionStatusBarItemService = createDecorator<IExtensionStatusBarItemService>('IExtensionStatusBarItemService'); |
| 28 | + |
| 29 | +export interface IExtensionStatusBarItemService { |
| 30 | + readonly _serviceBrand: undefined; |
| 31 | + |
| 32 | + 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; |
| 33 | + |
| 34 | + hasEntry(id: string): boolean; |
| 35 | +} |
| 36 | + |
| 37 | + |
| 38 | +class ExtensionStatusBarItemService implements IExtensionStatusBarItemService { |
| 39 | + |
| 40 | + declare readonly _serviceBrand: undefined; |
| 41 | + |
| 42 | + private readonly _entries: Map<string, { accessor: IStatusbarEntryAccessor; alignment: MainThreadStatusBarAlignment; priority: number }> = new Map(); |
| 43 | + |
| 44 | + constructor(@IStatusbarService private readonly _statusbarService: IStatusbarService) { } |
| 45 | + |
| 46 | + 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 { |
| 47 | + // if there are icons in the text use the tooltip for the aria label |
| 48 | + let ariaLabel: string; |
| 49 | + let role: string | undefined = undefined; |
| 50 | + if (accessibilityInformation) { |
| 51 | + ariaLabel = accessibilityInformation.label; |
| 52 | + role = accessibilityInformation.role; |
| 53 | + } else { |
| 54 | + ariaLabel = getCodiconAriaLabel(text); |
| 55 | + if (tooltip) { |
| 56 | + const tooltipString = typeof tooltip === 'string' ? tooltip : tooltip.value; |
| 57 | + ariaLabel += `, ${tooltipString}`; |
| 58 | + } |
| 59 | + } |
| 60 | + const entry: IStatusbarEntry = { name, text, tooltip, command, color, backgroundColor, ariaLabel, role }; |
| 61 | + |
| 62 | + if (typeof priority === 'undefined') { |
| 63 | + priority = 0; |
| 64 | + } |
| 65 | + |
| 66 | + let alignment = alignLeft ? StatusbarAlignment.LEFT : StatusbarAlignment.RIGHT; |
| 67 | + |
| 68 | + // alignment and priority can only be set once (at creation time) |
| 69 | + const existingEntry = this._entries.get(entryId); |
| 70 | + if (existingEntry) { |
| 71 | + alignment = existingEntry.alignment; |
| 72 | + priority = existingEntry.priority; |
| 73 | + } |
| 74 | + |
| 75 | + // Create new entry if not existing |
| 76 | + if (!existingEntry) { |
| 77 | + let entryPriority: number | IStatusbarEntryPriority; |
| 78 | + if (typeof extensionId === 'string') { |
| 79 | + // We cannot enforce unique priorities across all extensions, so we |
| 80 | + // use the extension identifier as a secondary sort key to reduce |
| 81 | + // the likelyhood of collisions. |
| 82 | + // See https://github.com/microsoft/vscode/issues/177835 |
| 83 | + // See https://github.com/microsoft/vscode/issues/123827 |
| 84 | + entryPriority = { primary: priority, secondary: hash(extensionId) }; |
| 85 | + } else { |
| 86 | + entryPriority = priority; |
| 87 | + } |
| 88 | + |
| 89 | + this._entries.set(entryId, { |
| 90 | + accessor: this._statusbarService.addEntry(entry, id, alignment, entryPriority), |
| 91 | + alignment, |
| 92 | + priority |
| 93 | + }); |
| 94 | + |
| 95 | + } else { |
| 96 | + // Otherwise update |
| 97 | + existingEntry.accessor.update(entry); |
| 98 | + } |
| 99 | + |
| 100 | + return toDisposable(() => { |
| 101 | + const entry = this._entries.get(entryId); |
| 102 | + if (entry) { |
| 103 | + entry.accessor.dispose(); |
| 104 | + this._entries.delete(entryId); |
| 105 | + } |
| 106 | + }); |
| 107 | + } |
| 108 | + |
| 109 | + hasEntry(id: string): boolean { |
| 110 | + return this._entries.has(id); |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +registerSingleton(IExtensionStatusBarItemService, ExtensionStatusBarItemService, InstantiationType.Delayed); |
| 115 | + |
| 116 | +// --- extension point and reading of it |
| 117 | + |
| 118 | +interface IUserFriendlyStatusItemEntry { |
| 119 | + id: string; |
| 120 | + name: string; |
| 121 | + text: string; |
| 122 | + alignment: 'left' | 'right'; |
| 123 | + command?: string; |
| 124 | + priority?: number; |
| 125 | +} |
| 126 | + |
| 127 | +function isUserFriendlyStatusItemEntry(obj: any): obj is IUserFriendlyStatusItemEntry { |
| 128 | + return (typeof obj.id === 'string' && obj.id.length > 0) |
| 129 | + && typeof obj.name === 'string' |
| 130 | + && typeof obj.text === 'string' |
| 131 | + && (obj.alignment === 'left' || obj.alignment === 'right') |
| 132 | + && (obj.command === undefined || typeof obj.command === 'string') |
| 133 | + && (obj.priority === undefined || typeof obj.priority === 'number'); |
| 134 | +} |
| 135 | + |
| 136 | +const statusBarItemSchema: IJSONSchema = { |
| 137 | + type: 'object', |
| 138 | + required: ['id', 'text', 'alignment', 'name'], |
| 139 | + properties: { |
| 140 | + id: { |
| 141 | + type: 'string', |
| 142 | + description: localize('id', 'The unique identifier of the status bar entry.') |
| 143 | + }, |
| 144 | + name: { |
| 145 | + type: 'string', |
| 146 | + description: localize('name', 'The name of the status bar entry.') |
| 147 | + }, |
| 148 | + text: { |
| 149 | + type: 'string', |
| 150 | + description: localize('text', 'The text to display in the status bar entry.') |
| 151 | + }, |
| 152 | + command: { |
| 153 | + type: 'string', |
| 154 | + description: localize('command', 'The command to execute when the status bar entry is clicked.') |
| 155 | + }, |
| 156 | + alignment: { |
| 157 | + type: 'string', |
| 158 | + enum: ['left', 'right'], |
| 159 | + description: localize('alignment', 'The alignment of the status bar entry.') |
| 160 | + }, |
| 161 | + priority: { |
| 162 | + type: 'number', |
| 163 | + description: localize('priority', 'The priority of the status bar entry.') |
| 164 | + } |
| 165 | + } |
| 166 | +}; |
| 167 | + |
| 168 | +const statusBarItemsSchema: IJSONSchema = { |
| 169 | + description: localize('vscode.extension.contributes.statusBarItems', "Contributes items to the status bar."), |
| 170 | + oneOf: [ |
| 171 | + statusBarItemSchema, |
| 172 | + { |
| 173 | + type: 'array', |
| 174 | + items: statusBarItemSchema |
| 175 | + } |
| 176 | + ] |
| 177 | +}; |
| 178 | + |
| 179 | +const statusBarItemsExtensionPoint = ExtensionsRegistry.registerExtensionPoint<IUserFriendlyStatusItemEntry | IUserFriendlyStatusItemEntry[]>({ |
| 180 | + extensionPoint: 'statusBarItems', |
| 181 | + jsonSchema: statusBarItemsSchema, |
| 182 | +}); |
| 183 | + |
| 184 | +export class StatusBarItemsExtensionPoint { |
| 185 | + |
| 186 | + constructor(@IExtensionStatusBarItemService statusBarItemsService: IExtensionStatusBarItemService) { |
| 187 | + |
| 188 | + const contributions = new DisposableStore(); |
| 189 | + |
| 190 | + statusBarItemsExtensionPoint.setHandler((extensions) => { |
| 191 | + |
| 192 | + contributions.clear(); |
| 193 | + |
| 194 | + for (const entry of extensions) { |
| 195 | + |
| 196 | + if (!isProposedApiEnabled(entry.description, 'contribStatusBarItems')) { |
| 197 | + entry.collector.error(`The ${statusBarItemsExtensionPoint.name} is proposed API`); |
| 198 | + continue; |
| 199 | + } |
| 200 | + |
| 201 | + const { value, collector } = entry; |
| 202 | + |
| 203 | + for (const candidate of Iterable.wrap(value)) { |
| 204 | + if (!isUserFriendlyStatusItemEntry(candidate)) { |
| 205 | + collector.error(localize('invalid', "Invalid status bar item contribution.")); |
| 206 | + continue; |
| 207 | + } |
| 208 | + |
| 209 | + const fullItemId = asStatusBarItemIdentifier(entry.description.identifier, candidate.id); |
| 210 | + |
| 211 | + contributions.add(statusBarItemsService.setOrUpdateEntry( |
| 212 | + fullItemId, |
| 213 | + fullItemId, |
| 214 | + ExtensionIdentifier.toKey(entry.description.identifier), |
| 215 | + candidate.name ?? entry.description.displayName ?? entry.description.name, |
| 216 | + candidate.text, |
| 217 | + undefined, undefined, undefined, undefined, |
| 218 | + candidate.alignment === 'left', |
| 219 | + candidate.priority, |
| 220 | + undefined |
| 221 | + )); |
| 222 | + } |
| 223 | + } |
| 224 | + }); |
| 225 | + } |
| 226 | +} |
0 commit comments