Skip to content

Commit 26852e0

Browse files
committed
support to hide menu item from their context menu,
have persisted menu item hide states in menu service, create `MenuItemAction` with an util that can hide itself and its siblings (from the same menu), some adoptions
1 parent 21147b8 commit 26852e0

File tree

15 files changed

+215
-61
lines changed

15 files changed

+215
-61
lines changed

src/vs/platform/actions/browser/dropdownWithPrimaryActionViewItem.ts

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

6-
import { IContextMenuProvider } from 'vs/base/browser/contextmenu';
76
import * as DOM from 'vs/base/browser/dom';
87
import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent';
98
import { ActionViewItem, BaseActionViewItem } from 'vs/base/browser/ui/actionbar/actionViewItems';
@@ -18,6 +17,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
1817
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
1918
import { INotificationService } from 'vs/platform/notification/common/notification';
2019
import { IThemeService } from 'vs/platform/theme/common/themeService';
20+
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
2121

2222
export interface IDropdownWithPrimaryActionViewItemOptions {
2323
getKeyBinding?: (action: IAction) => ResolvedKeybinding | undefined;
@@ -38,15 +38,15 @@ export class DropdownWithPrimaryActionViewItem extends BaseActionViewItem {
3838
dropdownAction: IAction,
3939
dropdownMenuActions: IAction[],
4040
className: string,
41-
private readonly _contextMenuProvider: IContextMenuProvider,
41+
private readonly _contextMenuProvider: IContextMenuService,
4242
private readonly _options: IDropdownWithPrimaryActionViewItemOptions | undefined,
4343
@IKeybindingService _keybindingService: IKeybindingService,
4444
@INotificationService _notificationService: INotificationService,
4545
@IContextKeyService _contextKeyService: IContextKeyService,
4646
@IThemeService _themeService: IThemeService
4747
) {
4848
super(null, primaryAction);
49-
this._primaryAction = new MenuEntryActionViewItem(primaryAction, undefined, _keybindingService, _notificationService, _contextKeyService, _themeService);
49+
this._primaryAction = new MenuEntryActionViewItem(primaryAction, undefined, _keybindingService, _notificationService, _contextKeyService, _themeService, _contextMenuProvider);
5050
this._dropdown = new DropdownMenuActionViewItem(dropdownAction, dropdownMenuActions, this._contextMenuProvider, {
5151
menuAsChild: true,
5252
classNames: ['codicon', 'codicon-chevron-down'],

src/vs/platform/actions/browser/menuEntryActionViewItem.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,12 +206,16 @@ export class MenuEntryActionViewItem extends ActionViewItem {
206206

207207

208208
this._register(addDisposableListener(container, 'contextmenu', event => {
209+
if (!this._menuItemAction.hideActions) {
210+
return;
211+
}
212+
209213
event.preventDefault();
210214
event.stopPropagation();
211215

212216
this._contextMenuService.showContextMenu({
213217
getAnchor: () => container,
214-
getActions: () => this._menuItemAction.hideActions.asList()
218+
getActions: () => this._menuItemAction.hideActions!.asList()
215219
});
216220
}, true));
217221
}

src/vs/platform/actions/common/actions.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,22 @@ export class SubmenuItemAction extends SubmenuAction {
353353
}
354354
}
355355

356+
export class MenuItemActionManageActions {
357+
constructor(
358+
private readonly _hideThis: IAction,
359+
private readonly _toggleAny: IAction[][],
360+
) { }
361+
362+
asList(): IAction[] {
363+
let result: IAction[] = [this._hideThis];
364+
for (const n of this._toggleAny) {
365+
result.push(new Separator());
366+
result = result.concat(n);
367+
}
368+
return result;
369+
}
370+
}
371+
356372
// implements IAction, does NOT extend Action, so that no one
357373
// subscribes to events of Action or modified properties
358374
export class MenuItemAction implements IAction {
@@ -373,6 +389,7 @@ export class MenuItemAction implements IAction {
373389
item: ICommandAction,
374390
alt: ICommandAction | undefined,
375391
options: IMenuActionOptions | undefined,
392+
readonly hideActions: MenuItemActionManageActions | undefined,
376393
@IContextKeyService contextKeyService: IContextKeyService,
377394
@ICommandService private _commandService: ICommandService
378395
) {
@@ -399,7 +416,7 @@ export class MenuItemAction implements IAction {
399416
}
400417

401418
this.item = item;
402-
this.alt = alt ? new MenuItemAction(alt, undefined, options, contextKeyService, _commandService) : undefined;
419+
this.alt = alt ? new MenuItemAction(alt, undefined, options, hideActions, contextKeyService, _commandService) : undefined;
403420
this._options = options;
404421
if (ThemeIcon.isThemeIcon(item.icon)) {
405422
this.class = CSSIcon.asClassName(item.icon);

src/vs/platform/actions/common/menuService.ts

Lines changed: 168 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,98 @@
66
import { RunOnceScheduler } from 'vs/base/common/async';
77
import { Emitter, Event } from 'vs/base/common/event';
88
import { DisposableStore } from 'vs/base/common/lifecycle';
9-
import { IMenu, IMenuActionOptions, IMenuCreateOptions, IMenuItem, IMenuService, isIMenuItem, ISubmenuItem, MenuId, MenuItemAction, MenuRegistry, SubmenuItemAction } from 'vs/platform/actions/common/actions';
10-
import { ILocalizedString } from 'vs/platform/action/common/action';
9+
import { IMenu, IMenuActionOptions, IMenuCreateOptions, IMenuItem, IMenuService, isIMenuItem, ISubmenuItem, MenuId, MenuItemAction, MenuItemActionManageActions, MenuRegistry, SubmenuItemAction } from 'vs/platform/actions/common/actions';
10+
import { ICommandAction, ILocalizedString } from 'vs/platform/action/common/action';
1111
import { ICommandService } from 'vs/platform/commands/common/commands';
1212
import { ContextKeyExpression, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
13+
import { IAction, SubmenuAction } from 'vs/base/common/actions';
14+
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
15+
import { removeFastWithoutKeepingOrder } from 'vs/base/common/arrays';
16+
import { localize } from 'vs/nls';
1317

1418
export class MenuService implements IMenuService {
1519

1620
declare readonly _serviceBrand: undefined;
1721

22+
private readonly _hiddenStates: PersistedMenuHideState;
23+
1824
constructor(
19-
@ICommandService private readonly _commandService: ICommandService
25+
@ICommandService private readonly _commandService: ICommandService,
26+
@IStorageService storageService: IStorageService,
2027
) {
21-
//
28+
this._hiddenStates = new PersistedMenuHideState(storageService);
2229
}
2330

2431
/**
2532
* Create a new menu for the given menu identifier. A menu sends events when it's entries
26-
* have changed (placement, enablement, checked-state). By default it does send events for
27-
* sub menu entries. That is more expensive and must be explicitly enabled with the
33+
* have changed (placement, enablement, checked-state). By default it does not send events for
34+
* submenu entries. That is more expensive and must be explicitly enabled with the
2835
* `emitEventsForSubmenuChanges` flag.
2936
*/
3037
createMenu(id: MenuId, contextKeyService: IContextKeyService, options?: IMenuCreateOptions): IMenu {
31-
return new Menu(id, { emitEventsForSubmenuChanges: false, eventDebounceDelay: 50, ...options }, this._commandService, contextKeyService, this);
38+
return new Menu(id, this._hiddenStates, { emitEventsForSubmenuChanges: false, eventDebounceDelay: 50, ...options }, this._commandService, contextKeyService, this);
3239
}
3340
}
3441

42+
class PersistedMenuHideState {
43+
44+
private static readonly _key = 'menu.hiddenCommands';
45+
46+
readonly onDidChange: Event<any>;
47+
private readonly _disposables = new DisposableStore();
48+
private readonly _data: Record<string, string[] | undefined>;
49+
50+
constructor(@IStorageService private readonly _storageService: IStorageService) {
51+
try {
52+
const raw = _storageService.get(PersistedMenuHideState._key, StorageScope.PROFILE, '{}');
53+
this._data = JSON.parse(raw);
54+
} catch (err) {
55+
this._data = Object.create(null);
56+
}
57+
58+
this.onDidChange = Event.filter(_storageService.onDidChangeValue, e => e.key === PersistedMenuHideState._key, this._disposables);
59+
}
60+
61+
dispose() {
62+
this._disposables.dispose();
63+
}
64+
65+
isHidden(menu: MenuId, commandId: string): boolean {
66+
return this._data[menu.id]?.includes(commandId) ?? false;
67+
}
68+
69+
updateHidden(menu: MenuId, commandId: string, hidden: boolean): void {
70+
const entries = this._data[menu.id];
71+
if (!hidden) {
72+
// remove and cleanup
73+
if (entries) {
74+
const idx = entries.indexOf(commandId);
75+
if (idx >= 0) {
76+
removeFastWithoutKeepingOrder(entries, idx);
77+
}
78+
if (entries.length === 0) {
79+
delete this._data[menu.id];
80+
}
81+
}
82+
} else {
83+
// add unless already added
84+
if (!entries) {
85+
this._data[menu.id] = [commandId];
86+
} else {
87+
const idx = entries.indexOf(commandId);
88+
if (idx < 0) {
89+
entries.push(commandId);
90+
}
91+
}
92+
}
93+
this._persist();
94+
}
95+
96+
private _persist(): void {
97+
const raw = JSON.stringify(this._data);
98+
this._storageService.store(PersistedMenuHideState._key, raw, StorageScope.PROFILE, StorageTarget.USER);
99+
}
100+
}
35101

36102
type MenuItemGroup = [string, Array<IMenuItem | ISubmenuItem>];
37103

@@ -47,6 +113,7 @@ class Menu implements IMenu {
47113

48114
constructor(
49115
private readonly _id: MenuId,
116+
private readonly _hiddenStates: PersistedMenuHideState,
50117
private readonly _options: Required<IMenuCreateOptions>,
51118
@ICommandService private readonly _commandService: ICommandService,
52119
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
@@ -68,24 +135,27 @@ class Menu implements IMenu {
68135
}
69136
}));
70137

71-
// When context keys change we need to check if the menu also has changed. However,
72-
// we only do that when someone listens on this menu because (1) context key events are
138+
// When context keys or storage state changes we need to check if the menu also has changed. However,
139+
// we only do that when someone listens on this menu because (1) these events are
73140
// firing often and (2) menu are often leaked
74-
const contextKeyListener = this._disposables.add(new DisposableStore());
75-
const startContextKeyListener = () => {
141+
const lazyListener = this._disposables.add(new DisposableStore());
142+
const startLazyListener = () => {
76143
const fireChangeSoon = new RunOnceScheduler(() => this._onDidChange.fire(this), _options.eventDebounceDelay);
77-
contextKeyListener.add(fireChangeSoon);
78-
contextKeyListener.add(_contextKeyService.onDidChangeContext(e => {
144+
lazyListener.add(fireChangeSoon);
145+
lazyListener.add(_contextKeyService.onDidChangeContext(e => {
79146
if (e.affectsSome(this._contextKeys)) {
80147
fireChangeSoon.schedule();
81148
}
82149
}));
150+
lazyListener.add(_hiddenStates.onDidChange(() => {
151+
fireChangeSoon.schedule();
152+
}));
83153
};
84154

85155
this._onDidChange = new Emitter({
86156
// start/stop context key listener
87-
onFirstListenerAdd: startContextKeyListener,
88-
onLastListenerRemove: contextKeyListener.clear.bind(contextKeyListener)
157+
onFirstListenerAdd: startLazyListener,
158+
onLastListenerRemove: lazyListener.clear.bind(lazyListener)
89159
});
90160
this.onDidChange = this._onDidChange.event;
91161

@@ -145,20 +215,47 @@ class Menu implements IMenu {
145215

146216
getActions(options?: IMenuActionOptions): [string, Array<MenuItemAction | SubmenuItemAction>][] {
147217
const result: [string, Array<MenuItemAction | SubmenuItemAction>][] = [];
218+
const allToggleActions: IAction[][] = [];
219+
148220
for (const group of this._menuGroups) {
149221
const [id, items] = group;
222+
223+
const toggleActions: IAction[] = [];
224+
150225
const activeActions: Array<MenuItemAction | SubmenuItemAction> = [];
151226
for (const item of items) {
152227
if (this._contextKeyService.contextMatchesRules(item.when)) {
153228
let action: MenuItemAction | SubmenuItemAction | undefined;
154229
if (isIMenuItem(item)) {
155-
action = new MenuItemAction(item.command, item.alt, options, this._contextKeyService, this._commandService);
230+
if (!this._hiddenStates.isHidden(this._id, item.command.id)) {
231+
action = new MenuItemAction(
232+
item.command, item.alt, options,
233+
new MenuItemActionManageActions(new HideMenuItemAction(this._id, item.command, this._hiddenStates), allToggleActions),
234+
this._contextKeyService, this._commandService
235+
);
236+
}
237+
// add toggle commmand
238+
toggleActions.push(new ToggleMenuItemAction(this._id, item.command, this._hiddenStates));
156239
} else {
157240
action = new SubmenuItemAction(item, this._menuService, this._contextKeyService, options);
158241
if (action.actions.length === 0) {
159242
action.dispose();
160243
action = undefined;
161244
}
245+
// add toggle submenu
246+
if (action) {
247+
// todo@jrieken this isn't good and O(n2) because this recurses for each submenu...
248+
const makeToggleCommand = (id: MenuId, action: IAction): IAction => {
249+
if (action instanceof SubmenuItemAction) {
250+
return new SubmenuAction(action.id, action.label, action.actions.map(a => makeToggleCommand(action.item.submenu, a)));
251+
} else if (action instanceof MenuItemAction) {
252+
return new ToggleMenuItemAction(id, action.item, this._hiddenStates);
253+
} else {
254+
return action;
255+
}
256+
};
257+
toggleActions.push(makeToggleCommand(this._id, action));
258+
}
162259
}
163260

164261
if (action) {
@@ -169,6 +266,9 @@ class Menu implements IMenu {
169266
if (activeActions.length > 0) {
170267
result.push([id, activeActions]);
171268
}
269+
if (toggleActions.length > 0) {
270+
allToggleActions.push(toggleActions);
271+
}
172272
}
173273
return result;
174274
}
@@ -231,3 +331,55 @@ class Menu implements IMenu {
231331
return aStr.localeCompare(bStr);
232332
}
233333
}
334+
335+
class ToggleMenuItemAction implements IAction {
336+
337+
readonly id: string;
338+
readonly label: string;
339+
readonly enabled: boolean = true;
340+
readonly tooltip: string = '';
341+
342+
readonly checked: boolean;
343+
readonly class: undefined;
344+
345+
run: () => void;
346+
347+
constructor(id: MenuId, command: ICommandAction, hiddenStates: PersistedMenuHideState) {
348+
this.id = `toggle/${id.id}/${command.id}`;
349+
this.label = typeof command.title === 'string' ? command.title : command.title.value;
350+
351+
let isHidden = hiddenStates.isHidden(id, command.id);
352+
this.checked = !isHidden;
353+
this.run = () => {
354+
isHidden = !isHidden;
355+
hiddenStates.updateHidden(id, command.id, isHidden);
356+
};
357+
}
358+
359+
dispose(): void {
360+
// NOTHING
361+
}
362+
}
363+
364+
class HideMenuItemAction implements IAction {
365+
366+
readonly id: string;
367+
readonly label: string;
368+
readonly enabled: boolean = true;
369+
readonly tooltip: string = '';
370+
371+
readonly checked: undefined;
372+
readonly class: undefined;
373+
374+
run: () => void;
375+
376+
constructor(id: MenuId, command: ICommandAction, hiddenStates: PersistedMenuHideState) {
377+
this.id = `hide/${id.id}/${command.id}`;
378+
this.label = localize('hide.label', 'Hide \'{0}\'', typeof command.title === 'string' ? command.title : command.title.value);
379+
this.run = () => { hiddenStates.updateHidden(id, command.id, true); };
380+
}
381+
382+
dispose(): void {
383+
// NOTHING
384+
}
385+
}

src/vs/platform/actions/test/common/menuService.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { isIMenuItem, MenuId, MenuRegistry } from 'vs/platform/actions/common/ac
99
import { MenuService } from 'vs/platform/actions/common/menuService';
1010
import { NullCommandService } from 'vs/platform/commands/common/commands';
1111
import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService';
12+
import { InMemoryStorageService } from 'vs/platform/storage/common/storage';
1213

1314
// --- service instances
1415

@@ -27,7 +28,7 @@ suite('MenuService', function () {
2728
let testMenuId: MenuId;
2829

2930
setup(function () {
30-
menuService = new MenuService(NullCommandService);
31+
menuService = new MenuService(NullCommandService, new InMemoryStorageService());
3132
testMenuId = new MenuId('testo');
3233
disposables.clear();
3334
});

src/vs/workbench/contrib/notebook/browser/controller/editActions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class DeleteCellAction extends MenuItemAction {
4949
},
5050
undefined,
5151
{ shouldForwardArgs: true },
52+
undefined,
5253
contextKeyService,
5354
commandService);
5455
}

src/vs/workbench/contrib/notebook/browser/diff/diffComponents.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ class PropertyHeader extends Disposable {
156156
this._toolbar = new ToolBar(cellToolbarContainer, this.contextMenuService, {
157157
actionViewItemProvider: action => {
158158
if (action instanceof MenuItemAction) {
159-
const item = new CodiconActionViewItem(action, this.keybindingService, this.notificationService, this.contextKeyService, this.themeService);
159+
const item = new CodiconActionViewItem(action, undefined, this.keybindingService, this.notificationService, this.contextKeyService, this.themeService, this.contextMenuService);
160160
return item;
161161
}
162162

src/vs/workbench/contrib/notebook/browser/diff/notebookTextDiffList.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ export class CellDiffSideBySideRenderer implements IListRenderer<SideBySideDiffE
187187
const toolbar = new ToolBar(cellToolbarContainer, this.contextMenuService, {
188188
actionViewItemProvider: action => {
189189
if (action instanceof MenuItemAction) {
190-
const item = new CodiconActionViewItem(action, this.keybindingService, this.notificationService, this.contextKeyService, this.themeService);
190+
const item = new CodiconActionViewItem(action, undefined, this.keybindingService, this.notificationService, this.contextKeyService, this.themeService, this.contextMenuService);
191191
return item;
192192
}
193193

0 commit comments

Comments
 (0)