Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/vs/base/browser/contextmenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export interface IContextMenuDelegate {
* A number selects a zero-based index in {@link getActions}, counting separators and each submenu as one item; non-integer or out-of-range indices are ignored.
*/
autoSelectItem?: boolean | number;
/** Whether action labels use menu mnemonic syntax. */
enableMnemonics?: boolean;
anchorAlignment?: AnchorAlignment;
anchorAxisAlignment?: AnchorAxisAlignment;
domForShadowRoot?: HTMLElement;
Expand Down
19 changes: 14 additions & 5 deletions src/vs/platform/actions/common/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { IAction, SubmenuAction } from '../../../base/common/actions.js';
import { Event, MicrotaskEmitter } from '../../../base/common/event.js';
import { DisposableStore, dispose, IDisposable, markAsSingleton, toDisposable } from '../../../base/common/lifecycle.js';
import { mnemonicMenuLabel, unmnemonicLabel } from '../../../base/common/labels.js';
import { LinkedList } from '../../../base/common/linkedList.js';
import { ThemeIcon } from '../../../base/common/themables.js';
import { ICommandAction, ICommandActionTitle, Icon, ILocalizedString } from '../../action/common/action.js';
Expand Down Expand Up @@ -366,6 +367,8 @@ export interface IMenuActionOptions {
args?: unknown[];
shouldForwardArgs?: boolean;
renderShortTitle?: boolean;
/** Render the explicit mnemonic title for menus that enable access keys. */
renderMnemonicTitle?: boolean;
}

export interface IMenuChangeEvent {
Expand Down Expand Up @@ -571,8 +574,10 @@ export class SubmenuItemAction extends SubmenuAction {
readonly item: ISubmenuItem,
readonly hideActions: IMenuItemHide | undefined,
actions: readonly IAction[],
options?: IMenuActionOptions,
) {
super(`submenuitem.${item.submenu.id}`, typeof item.title === 'string' ? item.title : item.title.value, actions, 'submenu');
const label = typeof item.title === 'string' ? item.title : item.title.value;
super(`submenuitem.${item.submenu.id}`, options?.renderMnemonicTitle ? unmnemonicLabel(label) : label, actions, 'submenu');
}
}

Expand All @@ -589,9 +594,12 @@ export interface IMenuItemHide {
export class MenuItemAction implements IAction {

static label(action: ICommandAction, options?: IMenuActionOptions): string {
return options?.renderShortTitle && action.shortTitle
? (typeof action.shortTitle === 'string' ? action.shortTitle : action.shortTitle.value)
: (typeof action.title === 'string' ? action.title : action.title.value);
const title = options?.renderShortTitle && action.shortTitle ? action.shortTitle : action.title;
if (options?.renderMnemonicTitle && title === action.title && typeof title !== 'string' && title.mnemonicTitle) {
return mnemonicMenuLabel(title.mnemonicTitle);
}
const label = typeof title === 'string' ? title : title.value;
return options?.renderMnemonicTitle ? unmnemonicLabel(label) : label;
}

readonly item: ICommandAction;
Expand Down Expand Up @@ -637,7 +645,8 @@ export class MenuItemAction implements IAction {
}

if (this.checked && toggled.title) {
this.label = typeof toggled.title === 'string' ? toggled.title : toggled.title.value;
const label = typeof toggled.title === 'string' ? toggled.title : toggled.title.value;
this.label = options?.renderMnemonicTitle ? unmnemonicLabel(label) : label;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/vs/platform/actions/common/menuService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ function createActionGroups(
const groups = createMenuActionGroups(item.submenu, options, hiddenStates, commandService, keybindingService, contextKeyService);
const submenuActions = Separator.join(...groups.map(g => g[1]));
if (submenuActions.length > 0) {
activeActions.push(new SubmenuItemAction(item, menuHide, submenuActions));
activeActions.push(new SubmenuItemAction(item, menuHide, submenuActions, options));
}
}
}
Expand Down
32 changes: 32 additions & 0 deletions src/vs/platform/actions/test/common/menuService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import assert from 'assert';
import sinon from 'sinon';
import { isMacintosh } from '../../../../base/common/platform.js';
import { Event } from '../../../../base/common/event.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
Expand Down Expand Up @@ -42,6 +43,37 @@ suite('MenuService', function () {
sinon.restore();
});

test('renders explicit mnemonics while preserving plain, short and toggled labels', () => {
const title = { value: 'Duplicate tab (D)', original: 'Duplicate tab (D)', mnemonicTitle: '&&Duplicate tab (D)' };
for (const command of [
{ id: 'explicit', title },
{ id: 'plain', title: 'Keep & Close' },
{ id: 'short', title, shortTitle: 'Copy & Keep' },
{ id: 'toggled', title, toggled: { condition: ContextKeyExpr.true(), title: 'Stop & Keep' } },
]) {
disposables.add(MenuRegistry.appendMenuItem(testMenuId, { command }));
}
assert.deepStrictEqual([undefined, { renderMnemonicTitle: true, renderShortTitle: true }].map(options =>
Object.fromEntries(menuService.getMenuActions(testMenuId, contextKeyService, options).flatMap(([, actions]) => actions.map(action => [action.id, action.label])))
), [
{ explicit: 'Duplicate tab (D)', plain: 'Keep & Close', short: 'Duplicate tab (D)', toggled: 'Stop & Keep' },
{ explicit: isMacintosh ? 'Duplicate tab (D)' : '&Duplicate tab (D)', plain: 'Keep && Close', short: 'Copy && Keep', toggled: 'Stop && Keep' },
]);
});

test('escapes ordinary ampersands in nested submenu labels', () => {
const child = new MenuId(`testo/${generateUuid()}`);
const nested = new MenuId(`testo/${generateUuid()}`);
disposables.add(MenuRegistry.appendMenuItem(testMenuId, { title: 'Split & Move', submenu: child }));
disposables.add(MenuRegistry.appendMenuItem(child, { title: 'Copy & Keep', submenu: nested }));
disposables.add(MenuRegistry.appendMenuItem(nested, { command: { id: 'plain', title: 'Keep & Close' } }));
const action = menuService.getMenuActions(testMenuId, contextKeyService, { renderMnemonicTitle: true })[0][1][0];
assert.ok(action instanceof SubmenuItemAction);
const inner = action.actions[0];
assert.ok(inner instanceof SubmenuItemAction);
assert.deepStrictEqual([action.label, inner.label, inner.actions[0].label], ['Split && Move', 'Copy && Keep', 'Keep && Close']);
});

test('createMenu collects menu items only once', () => {
const getMenuItems = sinon.spy(MenuRegistry, 'getMenuItems');
disposables.add(menuService.createMenu(testMenuId, contextKeyService));
Expand Down
1 change: 1 addition & 0 deletions src/vs/platform/browserView/common/browserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export enum BrowserViewCommandId {
Open = `${commandPrefix}.open`,
OpenFile = `${commandPrefix}.openFile`,
NewTab = `${commandPrefix}.newTab`,
DuplicateTab = `${commandPrefix}.duplicateTab`,
QuickOpen = `${commandPrefix}.quickOpen`,
OpenOrList = `${commandPrefix}.openOrList`,
CloseAll = `${commandPrefix}.closeAll`,
Expand Down
1 change: 1 addition & 0 deletions src/vs/platform/contextview/browser/contextMenuHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export class ContextMenuHandler {
actionRunner.onWillRun(evt => this.onActionRun(evt, !delegate.skipTelemetry), this, menuDisposables);
actionRunner.onDidRun(this.onDidActionRun, this, menuDisposables);
menu = new Menu(container, actions, {
enableMnemonics: delegate.enableMnemonics,
actionViewItemProvider: delegate.getActionViewItem,
context: delegate.getActionsContext ? delegate.getActionsContext() : null,
actionRunner,
Expand Down
11 changes: 7 additions & 4 deletions src/vs/platform/contextview/browser/contextMenuService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ModifierKeyEmitter } from '../../../base/browser/dom.js';
import { IAction, Separator } from '../../../base/common/actions.js';
import { Emitter } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { isMacintosh } from '../../../base/common/platform.js';
import { getFlatContextMenuActions } from '../../actions/browser/menuEntryActionViewItem.js';
import { IMenuService, MenuId } from '../../actions/common/actions.js';
import { IContextKeyService } from '../../contextkey/common/contextkey.js';
Expand Down Expand Up @@ -55,10 +56,12 @@ export class ContextMenuService extends Disposable implements IContextMenuServic

showContextMenu(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): void {

delegate = ContextMenuMenuDelegate.transform(delegate, this.menuService, this.contextKeyService);
const enableMnemonics = !isMacintosh && ContextMenuMenuDelegate.is(delegate) && delegate.menuId === MenuId.EditorTitleContext && !delegate.getActions;
delegate = ContextMenuMenuDelegate.transform(delegate, this.menuService, this.contextKeyService, enableMnemonics);

this.contextMenuHandler.showContextMenu({
...delegate,
enableMnemonics,
onHide: (didCancel) => {
delegate.onHide?.(didCancel);

Expand All @@ -72,11 +75,11 @@ export class ContextMenuService extends Disposable implements IContextMenuServic

export namespace ContextMenuMenuDelegate {

function is(thing: IContextMenuDelegate | IContextMenuMenuDelegate): thing is IContextMenuMenuDelegate {
export function is(thing: IContextMenuDelegate | IContextMenuMenuDelegate): thing is IContextMenuMenuDelegate {
return thing && (<IContextMenuMenuDelegate>thing).menuId instanceof MenuId;
}

export function transform(delegate: IContextMenuDelegate | IContextMenuMenuDelegate, menuService: IMenuService, globalContextKeyService: IContextKeyService): IContextMenuDelegate {
export function transform(delegate: IContextMenuDelegate | IContextMenuMenuDelegate, menuService: IMenuService, globalContextKeyService: IContextKeyService, renderMnemonicTitle = false): IContextMenuDelegate {
if (!is(delegate)) {
return delegate;
}
Expand All @@ -86,7 +89,7 @@ export namespace ContextMenuMenuDelegate {
getActions: () => {
let target: IAction[] = [];
if (menuId) {
const menu = menuService.getMenuActions(menuId, contextKeyService ?? globalContextKeyService, menuActionOptions);
const menu = menuService.getMenuActions(menuId, contextKeyService ?? globalContextKeyService, { ...menuActionOptions, renderMnemonicTitle });
target = getFlatContextMenuActions(menu);
}
if (!delegate.getActions) {
Expand Down
5 changes: 4 additions & 1 deletion src/vs/workbench/browser/parts/editor/editorTabsControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { EditorPane } from './editorPane.js';
import { CONNECTED_EDITOR_TABS_SELECTOR, IEditorGroupMenuIds, IEditorGroupsView, IEditorGroupView, IEditorPartsView, IInternalEditorOpenOptions } from './editor.js';
import { IEditorCommandsContext, EditorResourceAccessor, IEditorPartOptions, SideBySideEditor, EditorsOrder, EditorInputCapabilities, IToolbarActions, GroupIdentifier, Verbosity } from '../../../common/editor.js';
import { EditorInput } from '../../../common/editor/editorInput.js';
import { ResourceContextKey, ActiveEditorPinnedContext, ActiveEditorStickyContext, ActiveEditorDirtyContext, ActiveEditorGroupLockedContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, ActiveEditorFirstInGroupContext, ActiveEditorAvailableEditorIdsContext, applyAvailableEditorIds, ActiveEditorLastInGroupContext, ActiveEditorCannotCloseContext } from '../../../common/contextkeys.js';
import { EditorTitleContextEditorIdContext, ResourceContextKey, ActiveEditorPinnedContext, ActiveEditorStickyContext, ActiveEditorDirtyContext, ActiveEditorGroupLockedContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, ActiveEditorFirstInGroupContext, ActiveEditorAvailableEditorIdsContext, applyAvailableEditorIds, ActiveEditorLastInGroupContext, ActiveEditorCannotCloseContext } from '../../../common/contextkeys.js';
import { AnchorAlignment } from '../../../../base/browser/ui/contextview/contextview.js';
import { assertReturnsDefined } from '../../../../base/common/types.js';
import { isFirefox } from '../../../../base/browser/browser.js';
Expand Down Expand Up @@ -129,6 +129,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC

private readonly contextMenuContextKeyService: IContextKeyService;
private resourceContext: ResourceContextKey;
private readonly editorTitleContextEditorId: IContextKey<string>;

private editorPinnedContext: IContextKey<boolean>;
private editorIsFirstContext: IContextKey<boolean>;
Expand Down Expand Up @@ -180,6 +181,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC

this.resourceContext = this._register(scopedInstantiationService.createInstance(ResourceContextKey));

this.editorTitleContextEditorId = EditorTitleContextEditorIdContext.bindTo(this.contextMenuContextKeyService);
this.editorPinnedContext = ActiveEditorPinnedContext.bindTo(this.contextMenuContextKeyService);
this.editorIsFirstContext = ActiveEditorFirstInGroupContext.bindTo(this.contextMenuContextKeyService);
this.editorIsLastContext = ActiveEditorLastInGroupContext.bindTo(this.contextMenuContextKeyService);
Expand Down Expand Up @@ -564,6 +566,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC
protected onTabContextMenu(editor: EditorInput, e: Event, node: HTMLElement): void {

// Update contexts based on editor picked and remember previous to restore
this.editorTitleContextEditorId.set(editor.editorId ?? '');
this.resourceContext.set(EditorResourceAccessor.getOriginalUri(editor, { supportSideBySide: SideBySideEditor.PRIMARY }));
this.editorPinnedContext.set(this.tabsModel.isPinned(editor));
this.editorIsFirstContext.set(this.tabsModel.isFirst(editor));
Expand Down
2 changes: 2 additions & 0 deletions src/vs/workbench/common/contextkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export const ActiveEditorCanSplitInGroupContext = new RawContextKey<boolean>('ac
export const ActiveEditorCannotCloseContext = new RawContextKey<boolean>('activeEditorCannotClose', false, localize('activeEditorCannotClose', "Whether the active editor cannot be closed through standard user actions"));

// Editor Kind Context Keys
/** The editor whose tab context menu is being shown, which may be inactive. */
export const EditorTitleContextEditorIdContext = new RawContextKey<string>('editorTitleContextEditorId', '', localize('editorTitleContextEditorId', "The identifier of the editor whose tab context menu is being shown"));
export const ActiveEditorContext = new RawContextKey<string | null>('activeEditor', null, { type: 'string', description: localize('activeEditor', "The identifier of the active editor") });
export const ActiveEditorAvailableEditorIdsContext = new RawContextKey<string>('activeEditorAvailableEditorIds', '', localize('activeEditorAvailableEditorIds', "The available editor identifiers that are usable for the active editor"));
export const TextCompareEditorVisibleContext = new RawContextKey<boolean>('textCompareEditorVisible', false, localize('textCompareEditorVisible', "Whether a text compare editor is visible"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb
import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js';
import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../services/editor/common/editorService.js';
import { IEditorGroup, IEditorGroupsService, GroupsOrder } from '../../../../services/editor/common/editorGroupsService.js';
import { EditorsOrder, EditorResourceAccessor, GroupIdentifier, SideBySideEditor } from '../../../../common/editor.js';
import { EditorsOrder, EditorResourceAccessor, GroupIdentifier, SideBySideEditor, IEditorCommandsContext } from '../../../../common/editor.js';
import { IQuickInputService, IQuickInputButton, IQuickPickItem, IQuickPickSeparator, QuickInputButtonLocation, IQuickPick } from '../../../../../platform/quickinput/common/quickInput.js';
import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js';
import { Emitter } from '../../../../../base/common/event.js';
Expand Down Expand Up @@ -45,7 +45,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../pla
import { IPreferencesService } from '../../../../services/preferences/common/preferences.js';
import { disposableTimeout } from '../../../../../base/common/async.js';
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { IsSessionsWindowContext, ResourceContextKey } from '../../../../common/contextkeys.js';
import { IsSessionsWindowContext, ResourceContextKey, EditorTitleContextEditorIdContext, MultipleEditorsSelectedInGroupContext } from '../../../../common/contextkeys.js';
import { matchesSomeScheme, Schemas } from '../../../../../base/common/network.js';
import { BrowserNavigationFeatures } from './browserNavigationFeatures.js';
import { ViewColumn } from '../../../../../platform/editor/common/editor.js';
Expand Down Expand Up @@ -439,6 +439,42 @@ class NewTabAction extends Action2 {
}
}

class DuplicateTabAction extends Action2 {
constructor() {
super({
id: BrowserViewCommandId.DuplicateTab,
precondition: MultipleEditorsSelectedInGroupContext.negate(),
title: {
...localize2('browser.duplicateTab', "Duplicate tab (D)"),
mnemonicTitle: localize({ key: 'browser.duplicateTabMnemonic', comment: ['&& denotes a mnemonic. Keep (D) as the visible access-key hint.'] }, "&&Duplicate tab (D)"),
},
category: BrowserActionCategory,
menu: {
id: MenuId.EditorTitleContext,
group: '1_open',
order: 20,
when: EditorTitleContextEditorIdContext.isEqualTo(BrowserEditorInput.EDITOR_ID),
},
});
}

async run(accessor: ServicesAccessor, _resource?: URI, context?: IEditorCommandsContext): Promise<void> {
const editorGroupsService = accessor.get(IEditorGroupsService);
const group = context ? editorGroupsService.getGroup(context.groupId) : editorGroupsService.activeGroup;
const source = context?.editorIndex !== undefined ? group?.getEditorByIndex(context.editorIndex) : group?.activeEditor;
if (!group || !(source instanceof BrowserEditorInput)) {
return;
}

const browserViewService = accessor.get(IBrowserViewWorkbenchService);
const duplicate = browserViewService.getOrCreateLazy({ ...source.serialize(), id: generateUuid() });
await accessor.get(IEditorService).openEditor(duplicate, {
pinned: true,
index: group.getIndexOfEditor(source) + 1,
}, group.id);
}
}

class CloseAllBrowserTabsAction extends Action2 {
constructor() {
super({
Expand Down Expand Up @@ -559,6 +595,7 @@ registerAction2(OpenIntegratedBrowserAction);
registerAction2(OpenFileInIntegratedBrowserAction);
registerAction2(OpenOrListBrowsersAction);
registerAction2(NewTabAction);
registerAction2(DuplicateTabAction);
registerAction2(CloseAllBrowserTabsAction);
registerAction2(CloseAllBrowserTabsInGroupAction);

Expand Down
Loading