Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/actions/common/menuService.ts
3296 views
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 { RunOnceScheduler } from '../../../base/common/async.js';
7
import { DebounceEmitter, Emitter, Event } from '../../../base/common/event.js';
8
import { DisposableStore } from '../../../base/common/lifecycle.js';
9
import { IMenu, IMenuActionOptions, IMenuChangeEvent, IMenuCreateOptions, IMenuItem, IMenuItemHide, IMenuService, isIMenuItem, isISubmenuItem, ISubmenuItem, MenuId, MenuItemAction, MenuRegistry, SubmenuItemAction } from './actions.js';
10
import { ICommandAction, ILocalizedString } from '../../action/common/action.js';
11
import { ICommandService } from '../../commands/common/commands.js';
12
import { ContextKeyExpression, IContextKeyService } from '../../contextkey/common/contextkey.js';
13
import { IAction, Separator, toAction } from '../../../base/common/actions.js';
14
import { IStorageService, StorageScope, StorageTarget } from '../../storage/common/storage.js';
15
import { removeFastWithoutKeepingOrder } from '../../../base/common/arrays.js';
16
import { localize } from '../../../nls.js';
17
import { IKeybindingService } from '../../keybinding/common/keybinding.js';
18
19
export class MenuService implements IMenuService {
20
21
declare readonly _serviceBrand: undefined;
22
23
private readonly _hiddenStates: PersistedMenuHideState;
24
25
constructor(
26
@ICommandService private readonly _commandService: ICommandService,
27
@IKeybindingService private readonly _keybindingService: IKeybindingService,
28
@IStorageService storageService: IStorageService,
29
) {
30
this._hiddenStates = new PersistedMenuHideState(storageService);
31
}
32
33
createMenu(id: MenuId, contextKeyService: IContextKeyService, options?: IMenuCreateOptions): IMenu {
34
return new MenuImpl(id, this._hiddenStates, { emitEventsForSubmenuChanges: false, eventDebounceDelay: 50, ...options }, this._commandService, this._keybindingService, contextKeyService);
35
}
36
37
getMenuActions(id: MenuId, contextKeyService: IContextKeyService, options?: IMenuActionOptions): [string, Array<MenuItemAction | SubmenuItemAction>][] {
38
const menu = new MenuImpl(id, this._hiddenStates, { emitEventsForSubmenuChanges: false, eventDebounceDelay: 50, ...options }, this._commandService, this._keybindingService, contextKeyService);
39
const actions = menu.getActions(options);
40
menu.dispose();
41
return actions;
42
}
43
44
getMenuContexts(id: MenuId): ReadonlySet<string> {
45
const menuInfo = new MenuInfoSnapshot(id, false);
46
return new Set<string>([...menuInfo.structureContextKeys, ...menuInfo.preconditionContextKeys, ...menuInfo.toggledContextKeys]);
47
}
48
49
resetHiddenStates(ids?: MenuId[]): void {
50
this._hiddenStates.reset(ids);
51
}
52
}
53
54
class PersistedMenuHideState {
55
56
private static readonly _key = 'menu.hiddenCommands';
57
58
private readonly _disposables = new DisposableStore();
59
private readonly _onDidChange = new Emitter<void>();
60
readonly onDidChange: Event<void> = this._onDidChange.event;
61
62
private _ignoreChangeEvent: boolean = false;
63
private _data: Record<string, string[] | undefined>;
64
65
private _hiddenByDefaultCache = new Map<string, boolean>();
66
67
constructor(@IStorageService private readonly _storageService: IStorageService) {
68
try {
69
const raw = _storageService.get(PersistedMenuHideState._key, StorageScope.PROFILE, '{}');
70
this._data = JSON.parse(raw);
71
} catch (err) {
72
this._data = Object.create(null);
73
}
74
75
this._disposables.add(_storageService.onDidChangeValue(StorageScope.PROFILE, PersistedMenuHideState._key, this._disposables)(() => {
76
if (!this._ignoreChangeEvent) {
77
try {
78
const raw = _storageService.get(PersistedMenuHideState._key, StorageScope.PROFILE, '{}');
79
this._data = JSON.parse(raw);
80
} catch (err) {
81
console.log('FAILED to read storage after UPDATE', err);
82
}
83
}
84
this._onDidChange.fire();
85
}));
86
}
87
88
dispose() {
89
this._onDidChange.dispose();
90
this._disposables.dispose();
91
}
92
93
private _isHiddenByDefault(menu: MenuId, commandId: string) {
94
return this._hiddenByDefaultCache.get(`${menu.id}/${commandId}`) ?? false;
95
}
96
97
setDefaultState(menu: MenuId, commandId: string, hidden: boolean): void {
98
this._hiddenByDefaultCache.set(`${menu.id}/${commandId}`, hidden);
99
}
100
101
isHidden(menu: MenuId, commandId: string): boolean {
102
const hiddenByDefault = this._isHiddenByDefault(menu, commandId);
103
const state = this._data[menu.id]?.includes(commandId) ?? false;
104
return hiddenByDefault ? !state : state;
105
}
106
107
updateHidden(menu: MenuId, commandId: string, hidden: boolean): void {
108
const hiddenByDefault = this._isHiddenByDefault(menu, commandId);
109
if (hiddenByDefault) {
110
hidden = !hidden;
111
}
112
const entries = this._data[menu.id];
113
if (!hidden) {
114
// remove and cleanup
115
if (entries) {
116
const idx = entries.indexOf(commandId);
117
if (idx >= 0) {
118
removeFastWithoutKeepingOrder(entries, idx);
119
}
120
if (entries.length === 0) {
121
delete this._data[menu.id];
122
}
123
}
124
} else {
125
// add unless already added
126
if (!entries) {
127
this._data[menu.id] = [commandId];
128
} else {
129
const idx = entries.indexOf(commandId);
130
if (idx < 0) {
131
entries.push(commandId);
132
}
133
}
134
}
135
this._persist();
136
}
137
138
reset(menus?: MenuId[]): void {
139
if (menus === undefined) {
140
// reset all
141
this._data = Object.create(null);
142
this._persist();
143
} else {
144
// reset only for a specific menu
145
for (const { id } of menus) {
146
if (this._data[id]) {
147
delete this._data[id];
148
}
149
}
150
this._persist();
151
}
152
}
153
154
private _persist(): void {
155
try {
156
this._ignoreChangeEvent = true;
157
const raw = JSON.stringify(this._data);
158
this._storageService.store(PersistedMenuHideState._key, raw, StorageScope.PROFILE, StorageTarget.USER);
159
} finally {
160
this._ignoreChangeEvent = false;
161
}
162
}
163
}
164
165
type MenuItemGroup = [string, Array<IMenuItem | ISubmenuItem>];
166
167
class MenuInfoSnapshot {
168
protected _menuGroups: MenuItemGroup[] = [];
169
private _allMenuIds: Set<MenuId> = new Set();
170
private _structureContextKeys: Set<string> = new Set();
171
private _preconditionContextKeys: Set<string> = new Set();
172
private _toggledContextKeys: Set<string> = new Set();
173
174
constructor(
175
protected readonly _id: MenuId,
176
protected readonly _collectContextKeysForSubmenus: boolean,
177
) {
178
this.refresh();
179
}
180
181
get allMenuIds(): ReadonlySet<MenuId> {
182
return this._allMenuIds;
183
}
184
185
get structureContextKeys(): ReadonlySet<string> {
186
return this._structureContextKeys;
187
}
188
189
get preconditionContextKeys(): ReadonlySet<string> {
190
return this._preconditionContextKeys;
191
}
192
193
get toggledContextKeys(): ReadonlySet<string> {
194
return this._toggledContextKeys;
195
}
196
197
refresh(): void {
198
199
// reset
200
this._menuGroups.length = 0;
201
this._allMenuIds.clear();
202
this._structureContextKeys.clear();
203
this._preconditionContextKeys.clear();
204
this._toggledContextKeys.clear();
205
206
const menuItems = this._sort(MenuRegistry.getMenuItems(this._id));
207
let group: MenuItemGroup | undefined;
208
209
for (const item of menuItems) {
210
// group by groupId
211
const groupName = item.group || '';
212
if (!group || group[0] !== groupName) {
213
group = [groupName, []];
214
this._menuGroups.push(group);
215
}
216
group[1].push(item);
217
218
// keep keys and submenu ids for eventing
219
this._collectContextKeysAndSubmenuIds(item);
220
}
221
this._allMenuIds.add(this._id);
222
}
223
224
protected _sort(menuItems: (IMenuItem | ISubmenuItem)[]) {
225
// no sorting needed in snapshot
226
return menuItems;
227
}
228
229
private _collectContextKeysAndSubmenuIds(item: IMenuItem | ISubmenuItem): void {
230
231
MenuInfoSnapshot._fillInKbExprKeys(item.when, this._structureContextKeys);
232
233
if (isIMenuItem(item)) {
234
// keep precondition keys for event if applicable
235
if (item.command.precondition) {
236
MenuInfoSnapshot._fillInKbExprKeys(item.command.precondition, this._preconditionContextKeys);
237
}
238
// keep toggled keys for event if applicable
239
if (item.command.toggled) {
240
const toggledExpression: ContextKeyExpression = (item.command.toggled as { condition: ContextKeyExpression }).condition || item.command.toggled;
241
MenuInfoSnapshot._fillInKbExprKeys(toggledExpression, this._toggledContextKeys);
242
}
243
244
} else if (this._collectContextKeysForSubmenus) {
245
// recursively collect context keys from submenus so that this
246
// menu fires events when context key changes affect submenus
247
MenuRegistry.getMenuItems(item.submenu).forEach(this._collectContextKeysAndSubmenuIds, this);
248
249
this._allMenuIds.add(item.submenu);
250
}
251
}
252
253
private static _fillInKbExprKeys(exp: ContextKeyExpression | undefined, set: Set<string>): void {
254
if (exp) {
255
for (const key of exp.keys()) {
256
set.add(key);
257
}
258
}
259
}
260
261
}
262
263
class MenuInfo extends MenuInfoSnapshot {
264
265
constructor(
266
_id: MenuId,
267
private readonly _hiddenStates: PersistedMenuHideState,
268
_collectContextKeysForSubmenus: boolean,
269
@ICommandService private readonly _commandService: ICommandService,
270
@IKeybindingService private readonly _keybindingService: IKeybindingService,
271
@IContextKeyService private readonly _contextKeyService: IContextKeyService
272
) {
273
super(_id, _collectContextKeysForSubmenus);
274
this.refresh();
275
}
276
277
createActionGroups(options: IMenuActionOptions | undefined): [string, Array<MenuItemAction | SubmenuItemAction>][] {
278
const result: [string, Array<MenuItemAction | SubmenuItemAction>][] = [];
279
280
for (const group of this._menuGroups) {
281
const [id, items] = group;
282
283
let activeActions: Array<MenuItemAction | SubmenuItemAction> | undefined;
284
for (const item of items) {
285
if (this._contextKeyService.contextMatchesRules(item.when)) {
286
const isMenuItem = isIMenuItem(item);
287
if (isMenuItem) {
288
this._hiddenStates.setDefaultState(this._id, item.command.id, !!item.isHiddenByDefault);
289
}
290
291
const menuHide = createMenuHide(this._id, isMenuItem ? item.command : item, this._hiddenStates);
292
if (isMenuItem) {
293
// MenuItemAction
294
const menuKeybinding = createConfigureKeybindingAction(this._commandService, this._keybindingService, item.command.id, item.when);
295
(activeActions ??= []).push(new MenuItemAction(item.command, item.alt, options, menuHide, menuKeybinding, this._contextKeyService, this._commandService));
296
} else {
297
// SubmenuItemAction
298
const groups = new MenuInfo(item.submenu, this._hiddenStates, this._collectContextKeysForSubmenus, this._commandService, this._keybindingService, this._contextKeyService).createActionGroups(options);
299
const submenuActions = Separator.join(...groups.map(g => g[1]));
300
if (submenuActions.length > 0) {
301
(activeActions ??= []).push(new SubmenuItemAction(item, menuHide, submenuActions));
302
}
303
}
304
}
305
}
306
if (activeActions && activeActions.length > 0) {
307
result.push([id, activeActions]);
308
}
309
}
310
return result;
311
}
312
313
protected override _sort(menuItems: (IMenuItem | ISubmenuItem)[]): (IMenuItem | ISubmenuItem)[] {
314
return menuItems.sort(MenuInfo._compareMenuItems);
315
}
316
317
private static _compareMenuItems(a: IMenuItem | ISubmenuItem, b: IMenuItem | ISubmenuItem): number {
318
319
const aGroup = a.group;
320
const bGroup = b.group;
321
322
if (aGroup !== bGroup) {
323
324
// Falsy groups come last
325
if (!aGroup) {
326
return 1;
327
} else if (!bGroup) {
328
return -1;
329
}
330
331
// 'navigation' group comes first
332
if (aGroup === 'navigation') {
333
return -1;
334
} else if (bGroup === 'navigation') {
335
return 1;
336
}
337
338
// lexical sort for groups
339
const value = aGroup.localeCompare(bGroup);
340
if (value !== 0) {
341
return value;
342
}
343
}
344
345
// sort on priority - default is 0
346
const aPrio = a.order || 0;
347
const bPrio = b.order || 0;
348
if (aPrio < bPrio) {
349
return -1;
350
} else if (aPrio > bPrio) {
351
return 1;
352
}
353
354
// sort on titles
355
return MenuInfo._compareTitles(
356
isIMenuItem(a) ? a.command.title : a.title,
357
isIMenuItem(b) ? b.command.title : b.title
358
);
359
}
360
361
private static _compareTitles(a: string | ILocalizedString, b: string | ILocalizedString) {
362
const aStr = typeof a === 'string' ? a : a.original;
363
const bStr = typeof b === 'string' ? b : b.original;
364
return aStr.localeCompare(bStr);
365
}
366
}
367
368
class MenuImpl implements IMenu {
369
370
private readonly _menuInfo: MenuInfo;
371
private readonly _disposables = new DisposableStore();
372
373
private readonly _onDidChange: Emitter<IMenuChangeEvent>;
374
readonly onDidChange: Event<IMenuChangeEvent>;
375
376
constructor(
377
id: MenuId,
378
hiddenStates: PersistedMenuHideState,
379
options: Required<IMenuCreateOptions>,
380
@ICommandService commandService: ICommandService,
381
@IKeybindingService keybindingService: IKeybindingService,
382
@IContextKeyService contextKeyService: IContextKeyService
383
) {
384
this._menuInfo = new MenuInfo(id, hiddenStates, options.emitEventsForSubmenuChanges, commandService, keybindingService, contextKeyService);
385
386
// Rebuild this menu whenever the menu registry reports an event for this MenuId.
387
// This usually happen while code and extensions are loaded and affects the over
388
// structure of the menu
389
const rebuildMenuSoon = new RunOnceScheduler(() => {
390
this._menuInfo.refresh();
391
this._onDidChange.fire({ menu: this, isStructuralChange: true, isEnablementChange: true, isToggleChange: true });
392
}, options.eventDebounceDelay);
393
this._disposables.add(rebuildMenuSoon);
394
this._disposables.add(MenuRegistry.onDidChangeMenu(e => {
395
for (const id of this._menuInfo.allMenuIds) {
396
if (e.has(id)) {
397
rebuildMenuSoon.schedule();
398
break;
399
}
400
}
401
}));
402
403
// When context keys or storage state changes we need to check if the menu also has changed. However,
404
// we only do that when someone listens on this menu because (1) these events are
405
// firing often and (2) menu are often leaked
406
const lazyListener = this._disposables.add(new DisposableStore());
407
408
const merge = (events: IMenuChangeEvent[]): IMenuChangeEvent => {
409
410
let isStructuralChange = false;
411
let isEnablementChange = false;
412
let isToggleChange = false;
413
414
for (const item of events) {
415
isStructuralChange = isStructuralChange || item.isStructuralChange;
416
isEnablementChange = isEnablementChange || item.isEnablementChange;
417
isToggleChange = isToggleChange || item.isToggleChange;
418
if (isStructuralChange && isEnablementChange && isToggleChange) {
419
// everything is TRUE, no need to continue iterating
420
break;
421
}
422
}
423
424
return { menu: this, isStructuralChange, isEnablementChange, isToggleChange };
425
};
426
427
const startLazyListener = () => {
428
429
lazyListener.add(contextKeyService.onDidChangeContext(e => {
430
const isStructuralChange = e.affectsSome(this._menuInfo.structureContextKeys);
431
const isEnablementChange = e.affectsSome(this._menuInfo.preconditionContextKeys);
432
const isToggleChange = e.affectsSome(this._menuInfo.toggledContextKeys);
433
if (isStructuralChange || isEnablementChange || isToggleChange) {
434
this._onDidChange.fire({ menu: this, isStructuralChange, isEnablementChange, isToggleChange });
435
}
436
}));
437
lazyListener.add(hiddenStates.onDidChange(e => {
438
this._onDidChange.fire({ menu: this, isStructuralChange: true, isEnablementChange: false, isToggleChange: false });
439
}));
440
};
441
442
this._onDidChange = new DebounceEmitter({
443
// start/stop context key listener
444
onWillAddFirstListener: startLazyListener,
445
onDidRemoveLastListener: lazyListener.clear.bind(lazyListener),
446
delay: options.eventDebounceDelay,
447
merge
448
});
449
this.onDidChange = this._onDidChange.event;
450
}
451
452
getActions(options?: IMenuActionOptions | undefined): [string, (MenuItemAction | SubmenuItemAction)[]][] {
453
return this._menuInfo.createActionGroups(options);
454
}
455
456
dispose(): void {
457
this._disposables.dispose();
458
this._onDidChange.dispose();
459
}
460
}
461
462
function createMenuHide(menu: MenuId, command: ICommandAction | ISubmenuItem, states: PersistedMenuHideState): IMenuItemHide {
463
464
const id = isISubmenuItem(command) ? command.submenu.id : command.id;
465
const title = typeof command.title === 'string' ? command.title : command.title.value;
466
467
const hide = toAction({
468
id: `hide/${menu.id}/${id}`,
469
label: localize('hide.label', 'Hide \'{0}\'', title),
470
run() { states.updateHidden(menu, id, true); }
471
});
472
473
const toggle = toAction({
474
id: `toggle/${menu.id}/${id}`,
475
label: title,
476
get checked() { return !states.isHidden(menu, id); },
477
run() { states.updateHidden(menu, id, !!this.checked); }
478
});
479
480
return {
481
hide,
482
toggle,
483
get isHidden() { return !toggle.checked; },
484
};
485
}
486
487
export function createConfigureKeybindingAction(commandService: ICommandService, keybindingService: IKeybindingService, commandId: string, when: ContextKeyExpression | undefined = undefined, enabled = true): IAction {
488
return toAction({
489
id: `configureKeybinding/${commandId}`,
490
label: localize('configure keybinding', "Configure Keybinding"),
491
enabled,
492
run() {
493
// Only set the when clause when there is no keybinding
494
// It is possible that the action and the keybinding have different when clauses
495
const hasKeybinding = !!keybindingService.lookupKeybinding(commandId); // This may only be called inside the `run()` method as it can be expensive on startup. #210529
496
const whenValue = !hasKeybinding && when ? when.serialize() : undefined;
497
commandService.executeCommand('workbench.action.openGlobalKeybindings', `@command:${commandId}` + (whenValue ? ` +when:${whenValue}` : ''));
498
}
499
});
500
}
501
502