Skip to content

Commit 578b961

Browse files
committed
add extension point for status bar items (first cut)
1 parent 327bf4d commit 578b961

File tree

7 files changed

+250
-71
lines changed

7 files changed

+250
-71
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: 10 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,90 +3,35 @@
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 });
77-
}
78-
79-
// Otherwise update
80-
else {
81-
existingEntry.accessor.update(entry);
82-
}
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+
this.entries.set(entryId, dispo);
8332
}
8433

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

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -609,8 +609,8 @@ 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+
$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;
613+
$dispose(id: string): void;
614614
}
615615

616616
export interface MainThreadStorageShape extends IDisposable {

src/vs/workbench/api/common/extHostStatusBar.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { MainContext, MainThreadStatusBarShape, IMainContext, ICommandDto } from
99
import { localize } from 'vs/nls';
1010
import { CommandsConverter } from 'vs/workbench/api/common/extHostCommands';
1111
import { DisposableStore } from 'vs/base/common/lifecycle';
12-
import { IExtensionDescription } from 'vs/platform/extensions/common/extensions';
12+
import { ExtensionIdentifier, IExtensionDescription } from 'vs/platform/extensions/common/extensions';
1313
import { MarkdownString } from 'vs/workbench/api/common/extHostTypeConverters';
1414
import { isNumber } from 'vs/base/common/types';
1515

@@ -27,7 +27,7 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem {
2727
#proxy: MainThreadStatusBarShape;
2828
#commands: CommandsConverter;
2929

30-
private _entryId: number;
30+
private readonly _entryId: string;
3131

3232
private _extension?: IExtensionDescription;
3333

@@ -59,8 +59,9 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem {
5959
this.#proxy = proxy;
6060
this.#commands = commands;
6161

62-
this._entryId = ExtHostStatusBarEntry.ID_GEN++;
63-
62+
this._entryId = id && extension
63+
? `${ExtensionIdentifier.toKey(extension.identifier)}.${id}`
64+
: String(ExtHostStatusBarEntry.ID_GEN++);
6465
this._extension = extension;
6566

6667
this._id = id;

src/vs/workbench/services/extensions/common/extensionsApiProposals.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const allApiProposals = Object.freeze({
2222
contribNotebookStaticPreloads: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribNotebookStaticPreloads.d.ts',
2323
contribRemoteHelp: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribRemoteHelp.d.ts',
2424
contribShareMenu: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribShareMenu.d.ts',
25+
contribStatusBarItems: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribStatusBarItems.d.ts',
2526
contribViewsRemote: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribViewsRemote.d.ts',
2627
contribViewsWelcome: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.contribViewsWelcome.d.ts',
2728
customEditorMove: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.customEditorMove.d.ts',
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
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+
// empty placeholder for status bar items contribution
7+
8+
// https://github.com/microsoft/vscode/issues/167874 @jrieken

0 commit comments

Comments
 (0)