Skip to content

Commit 123deca

Browse files
authored
Merge pull request microsoft#182763 from microsoft/joh/effective-kingfisher
add extension point for status bar items (first cut)
2 parents 558e126 + 890ca88 commit 123deca

File tree

8 files changed

+279
-74
lines changed

8 files changed

+279
-74
lines changed

src/vs/workbench/api/browser/extensionHost.contribution.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ColorExtensionPoint } from 'vs/workbench/services/themes/common/colorEx
1414
import { IconExtensionPoint } from 'vs/workbench/services/themes/common/iconExtensionPoint';
1515
import { TokenClassificationExtensionPoints } from 'vs/workbench/services/themes/common/tokenClassificationExtensionPoint';
1616
import { LanguageConfigurationFileHandler } from 'vs/workbench/contrib/codeEditor/browser/languageConfigurationExtensionPoint';
17+
import { StatusBarItemsExtensionPoint } from 'vs/workbench/api/browser/statusBarExtensionPoint';
1718

1819
// --- mainThread participants
1920
import './mainThreadLocalization';
@@ -96,6 +97,7 @@ export class ExtensionPoints implements IWorkbenchContribution {
9697
this.instantiationService.createInstance(IconExtensionPoint);
9798
this.instantiationService.createInstance(TokenClassificationExtensionPoints);
9899
this.instantiationService.createInstance(LanguageConfigurationFileHandler);
100+
this.instantiationService.createInstance(StatusBarItemsExtensionPoint);
99101
}
100102
}
101103

src/vs/workbench/api/browser/mainThreadStatusBar.ts

Lines changed: 14 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,90 +3,41 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { IStatusbarService, StatusbarAlignment as MainThreadStatusBarAlignment, IStatusbarEntryAccessor, IStatusbarEntry, StatusbarAlignment, IStatusbarEntryPriority } from 'vs/workbench/services/statusbar/browser/statusbar';
76
import { MainThreadStatusBarShape, MainContext } from '../common/extHost.protocol';
87
import { ThemeColor } from 'vs/base/common/themables';
98
import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers';
10-
import { dispose } from 'vs/base/common/lifecycle';
9+
import { DisposableMap } from 'vs/base/common/lifecycle';
1110
import { Command } from 'vs/editor/common/languages';
1211
import { IAccessibilityInformation } from 'vs/platform/accessibility/common/accessibility';
1312
import { IMarkdownString } from 'vs/base/common/htmlContent';
14-
import { getCodiconAriaLabel } from 'vs/base/common/iconLabels';
15-
import { hash } from 'vs/base/common/hash';
13+
import { IExtensionStatusBarItemService } from 'vs/workbench/api/browser/statusBarExtensionPoint';
1614

1715
@extHostNamedCustomer(MainContext.MainThreadStatusBar)
1816
export class MainThreadStatusBar implements MainThreadStatusBarShape {
1917

20-
private readonly entries: Map<number, { accessor: IStatusbarEntryAccessor; alignment: MainThreadStatusBarAlignment; priority: number }> = new Map();
18+
private readonly entries = new DisposableMap<string>();
2119

2220
constructor(
2321
_extHostContext: IExtHostContext,
24-
@IStatusbarService private readonly statusbarService: IStatusbarService
22+
@IExtensionStatusBarItemService private readonly statusbarService: IExtensionStatusBarItemService
2523
) { }
2624

2725
dispose(): void {
28-
this.entries.forEach(entry => entry.accessor.dispose());
29-
this.entries.clear();
26+
this.entries.dispose();
3027
}
3128

32-
$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 {
33-
// if there are icons in the text use the tooltip for the aria label
34-
let ariaLabel: string;
35-
let role: string | undefined = undefined;
36-
if (accessibilityInformation) {
37-
ariaLabel = accessibilityInformation.label;
38-
role = accessibilityInformation.role;
39-
} else {
40-
ariaLabel = getCodiconAriaLabel(text);
41-
if (tooltip) {
42-
const tooltipString = typeof tooltip === 'string' ? tooltip : tooltip.value;
43-
ariaLabel += `, ${tooltipString}`;
44-
}
45-
}
46-
const entry: IStatusbarEntry = { name, text, tooltip, command, color, backgroundColor, ariaLabel, role };
47-
48-
if (typeof priority === 'undefined') {
49-
priority = 0;
50-
}
51-
52-
const alignment = alignLeft ? StatusbarAlignment.LEFT : StatusbarAlignment.RIGHT;
53-
54-
// Reset existing entry if alignment or priority changed
55-
let existingEntry = this.entries.get(entryId);
56-
if (existingEntry && (existingEntry.alignment !== alignment || existingEntry.priority !== priority)) {
57-
dispose(existingEntry.accessor);
58-
this.entries.delete(entryId);
59-
existingEntry = undefined;
60-
}
61-
62-
// Create new entry if not existing
63-
if (!existingEntry) {
64-
let entryPriority: number | IStatusbarEntryPriority;
65-
if (typeof extensionId === 'string') {
66-
// We cannot enforce unique priorities across all extensions, so we
67-
// use the extension identifier as a secondary sort key to reduce
68-
// the likelyhood of collisions.
69-
// See https://github.com/microsoft/vscode/issues/177835
70-
// See https://github.com/microsoft/vscode/issues/123827
71-
entryPriority = { primary: priority, secondary: hash(extensionId) };
72-
} else {
73-
entryPriority = priority;
74-
}
75-
76-
this.entries.set(entryId, { accessor: this.statusbarService.addEntry(entry, id, alignment, entryPriority), alignment, priority });
29+
$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 {
30+
const dispo = this.statusbarService.setOrUpdateEntry(entryId, id, extensionId, name, text, tooltip, command, color, backgroundColor, alignLeft, priority, accessibilityInformation);
31+
if (!this.entries.has(entryId)) {
32+
this.entries.set(entryId, dispo);
7733
}
34+
}
7835

79-
// Otherwise update
80-
else {
81-
existingEntry.accessor.update(entry);
82-
}
36+
async $hasEntry(entryId: string): Promise<boolean> {
37+
return this.statusbarService.hasEntry(entryId);
8338
}
8439

85-
$dispose(id: number) {
86-
const entry = this.entries.get(id);
87-
if (entry) {
88-
dispose(entry.accessor);
89-
this.entries.delete(id);
90-
}
40+
$dispose(entryId: string) {
41+
this.entries.deleteAndDispose(entryId);
9142
}
9243
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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+
}

src/vs/workbench/api/common/extHost.protocol.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -609,8 +609,9 @@ export interface MainThreadQuickOpenShape extends IDisposable {
609609
}
610610

611611
export interface MainThreadStatusBarShape extends IDisposable {
612-
$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;
613-
$dispose(id: number): void;
612+
$hasEntry(id: string): Promise<boolean>;
613+
$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;
614+
$dispose(id: string): void;
614615
}
615616

616617
export interface MainThreadStorageShape extends IDisposable {

0 commit comments

Comments
 (0)