Support setting keyboard shortcut from Action Item Context Menu (#209431)

fixes #208221
This commit is contained in:
Benjamin Christopher Simmonds 2024-04-03 13:11:50 +02:00 committed by GitHub
parent 03ef5ffe3f
commit 5c00163b30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 99 additions and 24 deletions

View file

@ -212,26 +212,31 @@ export class WorkbenchToolBar extends ToolBar {
}
}
const primaryActions = [];
if (action instanceof MenuItemAction && action.menuKeybinding) {
primaryActions.push(action.menuKeybinding);
}
// add "hide foo" actions
let hideAction: IAction;
if (!noHide && (action instanceof MenuItemAction || action instanceof SubmenuItemAction)) {
if (!action.hideActions) {
// no context menu for MenuItemAction instances that support no hiding
// those are fake actions and need to be cleaned up
return;
}
hideAction = action.hideActions.hide;
primaryActions.push(action.hideActions.hide);
} else {
hideAction = toAction({
primaryActions.push(toAction({
id: 'label',
label: localize('hide', "Hide"),
enabled: false,
run() { }
});
}));
}
const actions = Separator.join([hideAction], toggleActions);
const actions = Separator.join(primaryActions, toggleActions);
// add "Reset Menu" action
if (this._options?.resetMenu && !menuIds) {

View file

@ -479,6 +479,7 @@ export class MenuItemAction implements IAction {
alt: ICommandAction | undefined,
options: IMenuActionOptions | undefined,
readonly hideActions: IMenuItemHide | undefined,
readonly menuKeybinding: IAction | undefined,
@IContextKeyService contextKeyService: IContextKeyService,
@ICommandService private _commandService: ICommandService
) {
@ -513,7 +514,7 @@ export class MenuItemAction implements IAction {
}
this.item = item;
this.alt = alt ? new MenuItemAction(alt, undefined, options, hideActions, contextKeyService, _commandService) : undefined;
this.alt = alt ? new MenuItemAction(alt, undefined, options, hideActions, undefined, contextKeyService, _commandService) : undefined;
this._options = options;
this.class = icon && ThemeIcon.asClassName(icon);

View file

@ -10,7 +10,7 @@ import { IMenu, IMenuActionOptions, IMenuChangeEvent, IMenuCreateOptions, IMenuI
import { ICommandAction, ILocalizedString } from 'vs/platform/action/common/action';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { ContextKeyExpression, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { Separator, toAction } from 'vs/base/common/actions';
import { IAction, Separator, toAction } from 'vs/base/common/actions';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { removeFastWithoutKeepingOrder } from 'vs/base/common/arrays';
import { localize } from 'vs/nls';
@ -162,7 +162,7 @@ class MenuInfo {
private readonly _hiddenStates: PersistedMenuHideState,
private readonly _collectContextKeysForSubmenus: boolean,
@ICommandService private readonly _commandService: ICommandService,
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
@IContextKeyService private readonly _contextKeyService: IContextKeyService
) {
this.refresh();
}
@ -245,8 +245,8 @@ class MenuInfo {
const menuHide = createMenuHide(this._id, isMenuItem ? item.command : item, this._hiddenStates);
if (isMenuItem) {
// MenuItemAction
activeActions.push(new MenuItemAction(item.command, item.alt, options, menuHide, this._contextKeyService, this._commandService));
const menuKeybinding = createMenuKeybindingAction(this._id, item.command, this._commandService);
activeActions.push(new MenuItemAction(item.command, item.alt, options, menuHide, menuKeybinding, this._contextKeyService, this._commandService));
} else {
// SubmenuItemAction
const groups = new MenuInfo(item.submenu, this._hiddenStates, this._collectContextKeysForSubmenus, this._commandService, this._contextKeyService).createActionGroups(options);
@ -336,7 +336,7 @@ class MenuImpl implements IMenu {
hiddenStates: PersistedMenuHideState,
options: Required<IMenuCreateOptions>,
@ICommandService commandService: ICommandService,
@IContextKeyService contextKeyService: IContextKeyService,
@IContextKeyService contextKeyService: IContextKeyService
) {
this._menuInfo = new MenuInfo(id, hiddenStates, options.emitEventsForSubmenuChanges, commandService, contextKeyService);
@ -437,3 +437,20 @@ function createMenuHide(menu: MenuId, command: ICommandAction | ISubmenuItem, st
get isHidden() { return !toggle.checked; },
};
}
function createMenuKeybindingAction(menu: MenuId, command: ICommandAction | ISubmenuItem, commandService: ICommandService): IAction | undefined {
if (isISubmenuItem(command)) {
return undefined;
}
const configureKeybindingAction = toAction({
id: `configureKeybinding/${menu.id}/${command.id}`,
label: localize('configure keybinding', "Configure Keybinding"),
run() {
const when = command.precondition?.serialize();
commandService.executeCommand('workbench.action.openGlobalKeybindings', `@command:${command.id}` + (when ? ` +when:${when}` : ''));
}
});
return configureKeybindingAction;
}

View file

@ -28,6 +28,7 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
export interface ICommandQuickPick extends IPickerQuickAccessItem {
readonly commandId: string;
readonly commandWhen?: string;
readonly commandAlias?: string;
readonly commandDescription?: ILocalizedString;
tfIdfScore?: number;

View file

@ -374,7 +374,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge
actionViewItemProvider: (action, options) => {
if (this.location === ChatAgentLocation.Panel) {
if ((action.id === SubmitAction.ID || action.id === CancelAction.ID) && action instanceof MenuItemAction) {
const dropdownAction = this.instantiationService.createInstance(MenuItemAction, { id: 'chat.moreExecuteActions', title: localize('notebook.moreExecuteActionsLabel', "More..."), icon: Codicon.chevronDown }, undefined, undefined, undefined);
const dropdownAction = this.instantiationService.createInstance(MenuItemAction, { id: 'chat.moreExecuteActions', title: localize('notebook.moreExecuteActionsLabel', "More..."), icon: Codicon.chevronDown }, undefined, undefined, undefined, undefined);
return this.instantiationService.createInstance(ChatSubmitDropdownActionItem, action, dropdownAction);
}
}

View file

@ -37,6 +37,7 @@ import { CommandInformationResult, IAiRelatedInformationService, RelatedInformat
import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { createKeybindingCommandQuery } from 'vs/workbench/services/preferences/browser/keybindingsEditorModel';
import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences';
export class CommandsQuickAccessProvider extends AbstractEditorCommandsQuickAccessProvider {
@ -132,7 +133,7 @@ export class CommandsQuickAccessProvider extends AbstractEditorCommandsQuickAcce
tooltip: localize('configure keybinding', "Configure Keybinding"),
}],
trigger: (): TriggerAction => {
this.preferencesService.openGlobalKeybindingSettings(false, { query: `@command:${picks.commandId}` });
this.preferencesService.openGlobalKeybindingSettings(false, { query: createKeybindingCommandQuery(picks.commandId, picks.commandWhen) });
return TriggerAction.CLOSE_PICKER;
},
}));
@ -243,6 +244,7 @@ export class CommandsQuickAccessProvider extends AbstractEditorCommandsQuickAcce
: { value: metadataDescription, original: metadataDescription };
globalCommandPicks.push({
commandId: action.item.id,
commandWhen: action.item.precondition?.serialize(),
commandAlias,
label: stripIcons(label),
commandDescription,

View file

@ -2082,7 +2082,7 @@ class SCMInputWidgetToolbar extends WorkbenchToolBar {
id: SCMInputWidgetCommandId.CancelAction,
title: localize('scmInputCancelAction', "Cancel"),
icon: Codicon.debugStop,
}, undefined, undefined, undefined, contextKeyService, commandService);
}, undefined, undefined, undefined, undefined, contextKeyService, commandService);
}
public setInput(input: ISCMInput): void {

View file

@ -396,7 +396,7 @@ export class TestingExplorerView extends ViewPane {
icon: group === TestRunProfileBitset.Run
? icons.testingRunAllIcon
: icons.testingDebugAllIcon,
}, undefined, undefined, undefined);
}, undefined, undefined, undefined, undefined);
const dropdownAction = new Action('selectRunConfig', 'Select Configuration...', 'codicon-chevron-down', true);
@ -486,7 +486,7 @@ class ResultSummaryView extends Disposable {
{ ...new ReRunLastRun().desc, icon: icons.testingRerunIcon },
{ ...new DebugLastRun().desc, icon: icons.testingDebugIcon },
{},
undefined,
undefined, undefined
), { icon: true, label: false });
this.render();

View file

@ -20,6 +20,7 @@ import { ICommandAction, ILocalizedString } from 'vs/platform/action/common/acti
import { isEmptyObject, isString } from 'vs/base/common/types';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { ExtensionIdentifier, ExtensionIdentifierMap, IExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey';
export const KEYBINDING_ENTRY_TEMPLATE_ID = 'keybinding.entry.template';
@ -33,9 +34,17 @@ interface ModifierLabels {
user: ModLabels;
}
export function createKeybindingCommandQuery(commandId: string, when?: string): string {
const whenPart = when ? ` +when:${when}` : '';
return `@command:${commandId}${whenPart}`;
}
const wordFilter = or(matchesPrefix, matchesWords, matchesContiguousSubString);
const COMMAND_REGEX = /@command:\s*([^\+]+)/i;
const WHEN_REGEX = /\+when:\s*(.+)/i;
const SOURCE_REGEX = /@source:\s*(user|default|system|extension)/i;
const EXTENSION_REGEX = /@ext:\s*((".+")|([^\s]+))/i;
const KEYBINDING_REGEX = /@keybinding:\s*((\".+\")|(\S+))/i;
export class KeybindingsEditorModel extends EditorModel {
@ -61,23 +70,38 @@ export class KeybindingsEditorModel extends EditorModel {
fetch(searchValue: string, sortByPrecedence: boolean = false): IKeybindingItemEntry[] {
let keybindingItems = sortByPrecedence ? this._keybindingItemsSortedByPrecedence : this._keybindingItems;
const commandIdMatches = /@command:\s*(.+)/i.exec(searchValue);
// @command:COMMAND_ID
const commandIdMatches = COMMAND_REGEX.exec(searchValue);
if (commandIdMatches && commandIdMatches[1]) {
return keybindingItems.filter(k => k.command === commandIdMatches[1])
.map(keybindingItem => (<IKeybindingItemEntry>{ id: KeybindingsEditorModel.getId(keybindingItem), keybindingItem, templateId: KEYBINDING_ENTRY_TEMPLATE_ID }));
const command = commandIdMatches[1].trim();
let filteredKeybindingItems = keybindingItems.filter(k => k.command === command);
// +when:WHEN_EXPRESSION
if (filteredKeybindingItems.length) {
const whenMatches = WHEN_REGEX.exec(searchValue);
if (whenMatches && whenMatches[1]) {
const whenValue = whenMatches[1].trim();
filteredKeybindingItems = this.filterByWhen(filteredKeybindingItems, command, whenValue);
}
}
return filteredKeybindingItems.map(keybindingItem => (<IKeybindingItemEntry>{ id: KeybindingsEditorModel.getId(keybindingItem), keybindingItem, templateId: KEYBINDING_ENTRY_TEMPLATE_ID }));
}
// @source:SOURCE
if (SOURCE_REGEX.test(searchValue)) {
keybindingItems = this.filterBySource(keybindingItems, searchValue);
searchValue = searchValue.replace(SOURCE_REGEX, '');
} else {
// @ext:EXTENSION_ID
const extensionMatches = EXTENSION_REGEX.exec(searchValue);
if (extensionMatches && (extensionMatches[2] || extensionMatches[3])) {
const extensionId = extensionMatches[2] ? extensionMatches[2].substring(1, extensionMatches[2].length - 1) : extensionMatches[3];
keybindingItems = this.filterByExtension(keybindingItems, extensionId);
searchValue = searchValue.replace(EXTENSION_REGEX, '');
} else {
const keybindingMatches = /@keybinding:\s*((\".+\")|(\S+))/i.exec(searchValue);
// @keybinding:KEYBINDING
const keybindingMatches = KEYBINDING_REGEX.exec(searchValue);
if (keybindingMatches && (keybindingMatches[2] || keybindingMatches[3])) {
searchValue = keybindingMatches[2] || `"${keybindingMatches[3]}"`;
}
@ -154,6 +178,26 @@ export class KeybindingsEditorModel extends EditorModel {
return result;
}
private filterByWhen(keybindingItems: IKeybindingItem[], command: string, when: string): IKeybindingItem[] {
if (keybindingItems.length === 0) {
return [];
}
// Check if a keybinding with the same command id and when clause exists
const keybindingItemsWithWhen = keybindingItems.filter(k => k.when === when);
if (keybindingItemsWithWhen.length) {
return keybindingItemsWithWhen;
}
// Create a new entry with the when clause which does not live in the model
// We can reuse some of the properties from the same command with different when clause
const commandLabel = keybindingItems[0].commandLabel;
const keybindingItem = new ResolvedKeybindingItem(undefined, command, null, ContextKeyExpr.deserialize(when), false, null, false);
const actionLabels = new Map([[command, commandLabel]]);
return [KeybindingsEditorModel.toKeybindingEntry(command, keybindingItem, actionLabels, this.getExtensionsMapping())];
}
private splitKeybindingWords(wordsSeparatedBySpaces: string[]): string[] {
const result: string[] = [];
for (const word of wordsSeparatedBySpaces) {
@ -163,10 +207,7 @@ export class KeybindingsEditorModel extends EditorModel {
}
override async resolve(actionLabels = new Map<string, string>()): Promise<void> {
const extensions = new ExtensionIdentifierMap<IExtensionDescription>();
for (const extension of this.extensionService.extensions) {
extensions.set(extension.identifier, extension);
}
const extensions = this.getExtensionsMapping();
this._keybindingItemsSortedByPrecedence = [];
const boundCommands: Map<string, boolean> = new Map<string, boolean>();
@ -192,6 +233,14 @@ export class KeybindingsEditorModel extends EditorModel {
return keybindingItem.command + (keybindingItem?.keybinding?.getAriaLabel() ?? '') + keybindingItem.when + (isString(keybindingItem.source) ? keybindingItem.source : keybindingItem.source.identifier.value);
}
private getExtensionsMapping(): ExtensionIdentifierMap<IExtensionDescription> {
const extensions = new ExtensionIdentifierMap<IExtensionDescription>();
for (const extension of this.extensionService.extensions) {
extensions.set(extension.identifier, extension);
}
return extensions;
}
private static compareKeybindingData(a: IKeybindingItem, b: IKeybindingItem): number {
if (a.keybinding && !b.keybinding) {
return -1;