diff --git a/src/vs/base/browser/contextmenu.ts b/src/vs/base/browser/contextmenu.ts index 7c98aecf15676b..fff55671549fc4 100644 --- a/src/vs/base/browser/contextmenu.ts +++ b/src/vs/base/browser/contextmenu.ts @@ -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; diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 03520d885f6e0a..a14395cfa05867 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -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'; @@ -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 { @@ -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'); } } @@ -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; @@ -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; } } diff --git a/src/vs/platform/actions/common/menuService.ts b/src/vs/platform/actions/common/menuService.ts index dcee609f072747..ae328d14bba5a2 100644 --- a/src/vs/platform/actions/common/menuService.ts +++ b/src/vs/platform/actions/common/menuService.ts @@ -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)); } } } diff --git a/src/vs/platform/actions/test/common/menuService.test.ts b/src/vs/platform/actions/test/common/menuService.test.ts index dd4bdfced714ce..701286732d7613 100644 --- a/src/vs/platform/actions/test/common/menuService.test.ts +++ b/src/vs/platform/actions/test/common/menuService.test.ts @@ -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'; @@ -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)); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index f061e9798afed9..ab0c0e10aa7260 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -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`, diff --git a/src/vs/platform/contextview/browser/contextMenuHandler.ts b/src/vs/platform/contextview/browser/contextMenuHandler.ts index 642ee8ffcf7b76..cef24773af5b05 100644 --- a/src/vs/platform/contextview/browser/contextMenuHandler.ts +++ b/src/vs/platform/contextview/browser/contextMenuHandler.ts @@ -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, diff --git a/src/vs/platform/contextview/browser/contextMenuService.ts b/src/vs/platform/contextview/browser/contextMenuService.ts index 3e234e5a16602e..90ca77d24ee00d 100644 --- a/src/vs/platform/contextview/browser/contextMenuService.ts +++ b/src/vs/platform/contextview/browser/contextMenuService.ts @@ -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'; @@ -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); @@ -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 && (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; } @@ -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) { diff --git a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts index 3c60093081e9aa..9a76a73afea5eb 100644 --- a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts @@ -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'; @@ -129,6 +129,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC private readonly contextMenuContextKeyService: IContextKeyService; private resourceContext: ResourceContextKey; + private readonly editorTitleContextEditorId: IContextKey; private editorPinnedContext: IContextKey; private editorIsFirstContext: IContextKey; @@ -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); @@ -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)); diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index 011c2ad137f294..ba45e282e47339 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -86,6 +86,8 @@ export const ActiveEditorCanSplitInGroupContext = new RawContextKey('ac export const ActiveEditorCannotCloseContext = new RawContextKey('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('editorTitleContextEditorId', '', localize('editorTitleContextEditorId', "The identifier of the editor whose tab context menu is being shown")); export const ActiveEditorContext = new RawContextKey('activeEditor', null, { type: 'string', description: localize('activeEditor', "The identifier of the active editor") }); export const ActiveEditorAvailableEditorIdsContext = new RawContextKey('activeEditorAvailableEditorIds', '', localize('activeEditorAvailableEditorIds', "The available editor identifiers that are usable for the active editor")); export const TextCompareEditorVisibleContext = new RawContextKey('textCompareEditorVisible', false, localize('textCompareEditorVisible', "Whether a text compare editor is visible")); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index ff5f9053675d46..6e56ccb4ffb3e9 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -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'; @@ -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'; @@ -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 { + 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({ @@ -559,6 +595,7 @@ registerAction2(OpenIntegratedBrowserAction); registerAction2(OpenFileInIntegratedBrowserAction); registerAction2(OpenOrListBrowsersAction); registerAction2(NewTabAction); +registerAction2(DuplicateTabAction); registerAction2(CloseAllBrowserTabsAction); registerAction2(CloseAllBrowserTabsInGroupAction); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/features/browserTabManagementFeatures.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/features/browserTabManagementFeatures.test.ts new file mode 100644 index 00000000000000..2d41d44adeabe6 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/features/browserTabManagementFeatures.test.ts @@ -0,0 +1,198 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Action } from '../../../../../../base/common/actions.js'; +import { toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { DeferredPromise } from '../../../../../../base/common/async.js'; +import { ContextMenuService } from '../../../../../../platform/contextview/browser/contextMenuService.js'; +import { IContextViewService, IContextViewDelegate } from '../../../../../../platform/contextview/browser/contextView.js'; +import { ContextKeyExpression, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { isMacintosh } from '../../../../../../base/common/platform.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { MenuId, MenuRegistry, isIMenuItem, IMenuService, MenuItemAction, IMenuActionOptions } from '../../../../../../platform/actions/common/actions.js'; +import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../../../platform/browserView/common/browserView.js'; +import { CommandsRegistry, ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { Context } from '../../../../../../platform/contextkey/browser/contextKeyService.js'; +import { IEditorOptions } from '../../../../../../platform/editor/common/editor.js'; +import { BrowserEditorInput, IBrowserEditorInputData } from '../../../common/browserEditorInput.js'; +import { IBrowserViewWorkbenchService } from '../../../common/browserView.js'; +import { EditorInput } from '../../../../../common/editor/editorInput.js'; +import { IEditorGroup, IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import '../../../electron-browser/features/browserTabManagementFeatures.js'; + +suite('Browser Duplicate Tab', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + for (const { url, context, session } of ['https://example.com/path?query=value#section', undefined].flatMap(url => + [undefined, { groupId: 7 }, { groupId: 7, editorIndex: 2 }].flatMap(context => + [undefined, { scope: BrowserViewStorageScope.Workspace, affinity: 'workspace', network: { authority: 'ssh-remote+host' } }].map(session => ({ url, context, session })) + ) + )) { + test(`duplicates ${context?.editorIndex !== undefined ? 'the clicked inactive tab' : context ? 'the specified group active tab' : 'the active tab'} with URL ${url} and ${session ? 'explicit' : 'default'} session`, async () => { + const instantiationService = workbenchInstantiationService(undefined, disposables); + const data: IBrowserEditorInputData = { + id: 'source', url, title: 'Source', favicon: 'data:image/png;base64,AA==', + associatedResource: URI.file('/workspace/index.html'), + session, + }; + let created: IBrowserEditorInputData | undefined; + instantiationService.stub(IBrowserViewWorkbenchService, new class extends mock() { + override getOrCreateLazy(options: IBrowserEditorInputData): BrowserEditorInput { + created = options; + return duplicate; + } + }); + const source = disposables.add(instantiationService.createInstance(BrowserEditorInput, data, async () => { throw new Error('Should not resolve source'); })); + const duplicate = disposables.add(instantiationService.createInstance(BrowserEditorInput, { id: 'duplicate' }, async () => { throw new Error('Should not resolve duplicate'); })); + const group = new class extends mock() { + override readonly id = 7; + override get activeEditor() { return context?.editorIndex !== undefined ? null : source; } + override getEditorByIndex(index: number) { return index === 2 ? source : undefined; } + override getIndexOfEditor(editor: EditorInput) { return editor === source ? 2 : -1; } + }; + instantiationService.stub(IEditorGroupsService, new class extends mock() { + override get activeGroup() { + assert.strictEqual(context, undefined, 'Explicit context must not use the active group'); + return group; + } + override getGroup(id: number) { return id === group.id ? group : undefined; } + }); + let opened: { editor: EditorInput; options: IEditorOptions | undefined; group: number | undefined } | undefined; + instantiationService.stub(IEditorService, 'openEditor', async (editor: EditorInput, options?: IEditorOptions, group?: number) => { + opened = { editor, options, group }; + return undefined; + }); + await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand(BrowserViewCommandId.DuplicateTab)!.handler(accessor, source.resource, context)); + assert.ok(created?.id && created.id !== source.id); + assert.deepStrictEqual({ ...created, id: source.id }, data); + assert.deepStrictEqual(opened, { editor: duplicate, options: { pinned: true, index: 3 }, group: 7 }); + }); + } + + test('ignores stale tab context and non-browser tabs', async () => { + const instantiationService = workbenchInstantiationService(undefined, disposables); + const nonBrowser = disposables.add(new class extends EditorInput { override get typeId() { return 'test'; } override get resource() { return undefined; } }()); + const group = new class extends mock() { + override getEditorByIndex(index: number) { return index === 0 ? nonBrowser : undefined; } + }; + instantiationService.stub(IEditorGroupsService, new class extends mock() { + override getGroup(id: number) { return id === 7 ? group : undefined; } + }); + instantiationService.stub(IBrowserViewWorkbenchService, new class extends mock() { + override getOrCreateLazy(): BrowserEditorInput { throw new Error('Should not create a browser'); } + }); + for (const context of [{ groupId: 7, editorIndex: 0 }, { groupId: 7, editorIndex: 99 }, { groupId: 99, editorIndex: 0 }]) { + await instantiationService.invokeFunction(accessor => CommandsRegistry.getCommand(BrowserViewCommandId.DuplicateTab)!.handler(accessor, undefined, context)); + } + }); + + test('disables duplication when multiple editors are selected', () => { + const item = MenuRegistry.getMenuItems(MenuId.EditorTitleContext).find(item => isIMenuItem(item) && item.command.id === BrowserViewCommandId.DuplicateTab); + assert.ok(item && isIMenuItem(item) && item.command.precondition); + const context = new Context(1, null); + assert.deepStrictEqual([false, true].map(selected => { + context.setValue('multipleEditorsSelectedInGroup', selected); + return item.command.precondition!.evaluate(context); + }), [true, false]); + }); + + test('declares the visible D hint and explicit menu mnemonic', () => { + const item = MenuRegistry.getMenuItems(MenuId.EditorTitleContext).find(item => isIMenuItem(item) && item.command.id === BrowserViewCommandId.DuplicateTab); + assert.ok(item && isIMenuItem(item)); + assert.deepStrictEqual(item.command.title, { + value: 'Duplicate tab (D)', original: 'Duplicate tab (D)', mnemonicTitle: '&&Duplicate tab (D)', + }); + }); + + (isMacintosh ? test.skip : test)('D runs Duplicate Tab from a custom context menu with the clicked tab context', async () => { + const instantiationService = workbenchInstantiationService(undefined, disposables); + instantiationService.stub(IContextKeyService, 'contextMatchesRules', (rules: ContextKeyExpression | undefined) => !rules || rules.evaluate(new Context(1, null))); + const host = document.createElement('div'); + document.body.appendChild(host); + disposables.add(toDisposable(() => host.remove())); + const executed = new DeferredPromise<{ id: string; args: unknown[] }>(); + instantiationService.stub(ICommandService, new class extends mock() { + override async executeCommand(id: string, ...args: unknown[]): Promise { + await executed.complete({ id, args }); + return undefined; + } + }); + instantiationService.stub(IMenuService, 'getMenuActions', (_menuId: MenuId, _contextKeyService: IContextKeyService, options?: IMenuActionOptions) => { + const item = MenuRegistry.getMenuItems(MenuId.EditorTitleContext).find(item => isIMenuItem(item) && item.command.id === BrowserViewCommandId.DuplicateTab); + assert.ok(item && isIMenuItem(item)); + return [['1_open', [instantiationService.createInstance(MenuItemAction, item.command, undefined, options, undefined, undefined)]]]; + }); + instantiationService.stub(IContextViewService, 'showContextView', (delegate: IContextViewDelegate) => { + const rendered = delegate.render(host); + if (rendered) { + disposables.add(rendered); + } + disposables.add(toDisposable(() => delegate.onHide?.())); + delegate.focus?.(); + return { close: () => delegate.onHide?.() }; + }); + const service = disposables.add(instantiationService.createInstance(ContextMenuService)); + const context = { groupId: 7, editorIndex: 0 }; + const resource = URI.parse('vscode-browser://source'); + service.showContextMenu({ + menuId: MenuId.EditorTitleContext, + menuActionOptions: { arg: resource, shouldForwardArgs: true }, + getAnchor: () => host, + getActionsContext: () => context, + }); + const item = host.querySelector('.action-menu-item')!; + assert.ok(item); + item.dispatchEvent(new KeyboardEvent('keydown', { key: 'd', code: 'KeyD', keyCode: 68, bubbles: true, cancelable: true })); + assert.deepStrictEqual(await executed.p, { id: BrowserViewCommandId.DuplicateTab, args: [resource, context] }); + }); + + test('custom mnemonic handling is scoped to registry-only editor title menus', () => { + const instantiationService = workbenchInstantiationService(undefined, disposables); + const host = document.createElement('div'); + document.body.appendChild(host); + disposables.add(toDisposable(() => host.remove())); + const raw = disposables.add(new Action('raw', 'Keep & Move')); + instantiationService.stub(IMenuService, 'getMenuActions', (_menuId: MenuId, _context: IContextKeyService, options?: IMenuActionOptions) => [['test', [ + instantiationService.createInstance(MenuItemAction, { id: 'test', title: { value: 'Duplicate', original: 'Duplicate', mnemonicTitle: '&&Duplicate' } }, undefined, options, undefined, undefined) + ]]]); + instantiationService.stub(IContextViewService, 'showContextView', (delegate: IContextViewDelegate) => { + host.replaceChildren(); + const rendered = delegate.render(host); + if (rendered) { + disposables.add(rendered); + } + disposables.add(toDisposable(() => delegate.onHide?.())); + return { close: () => delegate.onHide?.() }; + }); + const service = disposables.add(instantiationService.createInstance(ContextMenuService)); + const shortcuts = [ + { menuId: MenuId.EditorTitleContext }, + { menuId: MenuId.CommandPalette }, + { getActions: () => [raw] }, + { menuId: MenuId.EditorTitleContext, getActions: () => [raw] }, + ].map(delegate => { + service.showContextMenu({ ...delegate, getAnchor: () => host }); + return Array.from(host.querySelectorAll('[aria-keyshortcuts]')).map(item => item.getAttribute('aria-keyshortcuts')); + }); + assert.deepStrictEqual(shortcuts, [isMacintosh ? [] : ['d'], [], [], []]); + }); + + test('menu visibility follows the clicked editor including file-backed browser tabs', () => { + const item = MenuRegistry.getMenuItems(MenuId.EditorTitleContext).find(item => isIMenuItem(item) && item.command.id === BrowserViewCommandId.DuplicateTab); + assert.ok(item?.when); + const context = new Context(1, null); + assert.deepStrictEqual(['workbench.editors.textResourceEditor', BrowserEditorInput.EDITOR_ID].map(editorId => { + context.setValue('resourceScheme', 'file'); + context.setValue('activeEditor', editorId === BrowserEditorInput.EDITOR_ID ? 'workbench.editors.textResourceEditor' : BrowserEditorInput.EDITOR_ID); + context.setValue('editorTitleContextEditorId', editorId); + return item.when!.evaluate(context); + }), [false, true]); + }); +}); diff --git a/src/vs/workbench/services/contextmenu/electron-browser/contextmenuService.ts b/src/vs/workbench/services/contextmenu/electron-browser/contextmenuService.ts index e7d6e6eda71258..50066b0f332e08 100644 --- a/src/vs/workbench/services/contextmenu/electron-browser/contextmenuService.ts +++ b/src/vs/workbench/services/contextmenu/electron-browser/contextmenuService.ts @@ -9,7 +9,7 @@ import { IContextMenuMenuDelegate, IContextMenuService, IContextViewService } fr import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { getZoomFactor } from '../../../../base/browser/browser.js'; -import { unmnemonicLabel } from '../../../../base/common/labels.js'; +import { mnemonicMenuLabel, unmnemonicLabel } from '../../../../base/common/labels.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { getContextMenuAutoSelectItem, IContextMenuDelegate, IContextMenuEvent } from '../../../../base/browser/contextmenu.js'; import { createSingleCallFunction } from '../../../../base/common/functional.js'; @@ -24,7 +24,7 @@ import { stripIcons } from '../../../../base/common/iconLabels.js'; import { coalesce } from '../../../../base/common/arrays.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { AnchorAlignment, AnchorAxisAlignment, isAnchor } from '../../../../base/browser/ui/contextview/contextview.js'; -import { IMenuService } from '../../../../platform/actions/common/actions.js'; +import { IMenuService, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { IHostService } from '../../host/browser/host.js'; @@ -255,8 +255,11 @@ export class NativeContextMenuService extends Disposable implements IContextMenu } } + const title = entry instanceof MenuItemAction ? entry.item.title : undefined; + // Only use the mnemonic when the original title is displayed, not a short or toggled title. + const mnemonicTitle = typeof title === 'object' && title.value === entry.label ? title.mnemonicTitle : undefined; const item: IContextMenuItem = { - label: unmnemonicLabel(stripIcons(entry.label)).trim(), + label: (mnemonicTitle ? mnemonicMenuLabel(stripIcons(mnemonicTitle)) : unmnemonicLabel(stripIcons(entry.label))).trim(), checked: !!entry.checked, type, enabled: !!entry.enabled, diff --git a/src/vs/workbench/services/contextmenu/test/electron-browser/contextmenuService.test.ts b/src/vs/workbench/services/contextmenu/test/electron-browser/contextmenuService.test.ts index eb1e4cd970cc0c..33feb620846efa 100644 --- a/src/vs/workbench/services/contextmenu/test/electron-browser/contextmenuService.test.ts +++ b/src/vs/workbench/services/contextmenu/test/electron-browser/contextmenuService.test.ts @@ -10,9 +10,10 @@ import { mainWindow } from '../../../../../base/browser/window.js'; import { Action, ActionRunner, Separator, SubmenuAction, toAction } from '../../../../../base/common/actions.js'; import { DeferredPromise } from '../../../../../base/common/async.js'; import { toDisposable } from '../../../../../base/common/lifecycle.js'; -import { isLinux, isWindows } from '../../../../../base/common/platform.js'; +import { isLinux, isMacintosh, isWindows } from '../../../../../base/common/platform.js'; import { IContextMenuItem, IPopupOptions } from '../../../../../base/parts/contextmenu/common/contextmenu.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { FocusMode } from '../../../../../platform/native/common/native.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; import { IHostService } from '../../../host/browser/host.js'; @@ -120,6 +121,39 @@ suite('Native ContextMenuService', () => { } } + test('honors explicit menu mnemonics and escapes ordinary ampersands', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const action = instantiationService.createInstance(MenuItemAction, { + id: 'test.duplicate', + title: { value: 'Duplicate tab (D)', original: 'Duplicate tab (D)', mnemonicTitle: '&&Duplicate tab (D)' }, + }, undefined, undefined, undefined, undefined); + const ordinary = store.add(new Action('test.ordinary', 'Keep & Close')); + service.showContextMenu({ + getAnchor: () => mainWindow.document.body, + getActions: () => [action, ordinary], + }); + assert.deepStrictEqual(menu.map(item => ({ label: item.label, accelerator: item.accelerator })), [ + { label: isMacintosh ? 'Duplicate tab (D)' : '&Duplicate tab (D)', accelerator: undefined }, + { label: 'Keep && Close', accelerator: undefined }, + ]); + closeMenu!(); + }); + + test('does not apply the original mnemonic to a short title', () => { + const instantiationService = workbenchInstantiationService(undefined, store); + const action = instantiationService.createInstance(MenuItemAction, { + id: 'test.short', + title: { value: 'Duplicate tab (D)', original: 'Duplicate tab (D)', mnemonicTitle: '&&Duplicate tab (D)' }, + shortTitle: 'Copy', + }, undefined, { renderShortTitle: true }, undefined, undefined); + service.showContextMenu({ + getAnchor: () => mainWindow.document.body, + getActions: () => [action], + }); + assert.strictEqual(menu[0].label, 'Copy'); + closeMenu!(); + }); + test('does not activate the window when dismissing the menu', () => { const action = store.add(new Action('test', 'Test')); service.showContextMenu({