Path: blob/main/src/vs/workbench/services/keybinding/browser/keybindingService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as nls from '../../../../nls.js';67// base8import * as browser from '../../../../base/browser/browser.js';9import { BrowserFeatures, KeyboardSupport } from '../../../../base/browser/canIUse.js';10import * as dom from '../../../../base/browser/dom.js';11import { printKeyboardEvent, printStandardKeyboardEvent, StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';12import { mainWindow } from '../../../../base/browser/window.js';13import { DeferredPromise, RunOnceScheduler } from '../../../../base/common/async.js';14import { Emitter, Event } from '../../../../base/common/event.js';15import { parse } from '../../../../base/common/json.js';16import { IJSONSchema } from '../../../../base/common/jsonSchema.js';17import { UserSettingsLabelProvider } from '../../../../base/common/keybindingLabels.js';18import { KeybindingParser } from '../../../../base/common/keybindingParser.js';19import { Keybinding, KeyCodeChord, ResolvedKeybinding, ScanCodeChord } from '../../../../base/common/keybindings.js';20import { IMMUTABLE_CODE_TO_KEY_CODE, KeyCode, KeyCodeUtils, KeyMod, ScanCode, ScanCodeUtils } from '../../../../base/common/keyCodes.js';21import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';22import * as objects from '../../../../base/common/objects.js';23import { isMacintosh, OperatingSystem, OS } from '../../../../base/common/platform.js';24import { dirname } from '../../../../base/common/resources.js';2526// platform27import { ILocalizedString, isLocalizedString } from '../../../../platform/action/common/action.js';28import { MenuRegistry } from '../../../../platform/actions/common/actions.js';29import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js';30import { ContextKeyExpr, ContextKeyExpression, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';31import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';32import { FileOperation, IFileService } from '../../../../platform/files/common/files.js';33import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';34import { Extensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js';35import { AbstractKeybindingService } from '../../../../platform/keybinding/common/abstractKeybindingService.js';36import { IKeybindingService, IKeyboardEvent, KeybindingsSchemaContribution } from '../../../../platform/keybinding/common/keybinding.js';37import { KeybindingResolver } from '../../../../platform/keybinding/common/keybindingResolver.js';38import { IExtensionKeybindingRule, IKeybindingItem, KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';39import { ResolvedKeybindingItem } from '../../../../platform/keybinding/common/resolvedKeybindingItem.js';40import { IKeyboardLayoutService } from '../../../../platform/keyboardLayout/common/keyboardLayout.js';41import { IKeyboardMapper } from '../../../../platform/keyboardLayout/common/keyboardMapper.js';42import { ILogService } from '../../../../platform/log/common/log.js';43import { INotificationService } from '../../../../platform/notification/common/notification.js';44import { Registry } from '../../../../platform/registry/common/platform.js';45import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';46import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';4748// workbench49import { remove } from '../../../../base/common/arrays.js';50import { commandsExtensionPoint } from '../../actions/common/menusExtensionPoint.js';51import { IExtensionService } from '../../extensions/common/extensions.js';52import { ExtensionMessageCollector, ExtensionsRegistry } from '../../extensions/common/extensionsRegistry.js';53import { IHostService } from '../../host/browser/host.js';54import { IUserDataProfileService } from '../../userDataProfile/common/userDataProfile.js';55import { IUserKeybindingItem, KeybindingIO, OutputBuilder } from '../common/keybindingIO.js';56import { IKeyboard, INavigatorWithKeyboard } from './navigatorKeyboard.js';57import { getAllUnboundCommands } from './unboundCommands.js';5859interface ContributedKeyBinding {60command: string;61args?: any;62key: string;63when?: string;64mac?: string;65linux?: string;66win?: string;67}6869function isValidContributedKeyBinding(keyBinding: ContributedKeyBinding, rejects: string[]): boolean {70if (!keyBinding) {71rejects.push(nls.localize('nonempty', "expected non-empty value."));72return false;73}74if (typeof keyBinding.command !== 'string') {75rejects.push(nls.localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command'));76return false;77}78if (keyBinding.key && typeof keyBinding.key !== 'string') {79rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'key'));80return false;81}82if (keyBinding.when && typeof keyBinding.when !== 'string') {83rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when'));84return false;85}86if (keyBinding.mac && typeof keyBinding.mac !== 'string') {87rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'mac'));88return false;89}90if (keyBinding.linux && typeof keyBinding.linux !== 'string') {91rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'linux'));92return false;93}94if (keyBinding.win && typeof keyBinding.win !== 'string') {95rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'win'));96return false;97}98return true;99}100101const keybindingType: IJSONSchema = {102type: 'object',103default: { command: '', key: '' },104properties: {105command: {106description: nls.localize('vscode.extension.contributes.keybindings.command', 'Identifier of the command to run when keybinding is triggered.'),107type: 'string'108},109args: {110description: nls.localize('vscode.extension.contributes.keybindings.args', "Arguments to pass to the command to execute.")111},112key: {113description: nls.localize('vscode.extension.contributes.keybindings.key', 'Key or key sequence (separate keys with plus-sign and sequences with space, e.g. Ctrl+O and Ctrl+L L for a chord).'),114type: 'string'115},116mac: {117description: nls.localize('vscode.extension.contributes.keybindings.mac', 'Mac specific key or key sequence.'),118type: 'string'119},120linux: {121description: nls.localize('vscode.extension.contributes.keybindings.linux', 'Linux specific key or key sequence.'),122type: 'string'123},124win: {125description: nls.localize('vscode.extension.contributes.keybindings.win', 'Windows specific key or key sequence.'),126type: 'string'127},128when: {129description: nls.localize('vscode.extension.contributes.keybindings.when', 'Condition when the key is active.'),130type: 'string'131},132}133};134135const keybindingsExtPoint = ExtensionsRegistry.registerExtensionPoint<ContributedKeyBinding | ContributedKeyBinding[]>({136extensionPoint: 'keybindings',137deps: [commandsExtensionPoint],138jsonSchema: {139description: nls.localize('vscode.extension.contributes.keybindings', "Contributes keybindings."),140oneOf: [141keybindingType,142{143type: 'array',144items: keybindingType145}146]147}148});149150const NUMPAD_PRINTABLE_SCANCODES = [151ScanCode.NumpadDivide,152ScanCode.NumpadMultiply,153ScanCode.NumpadSubtract,154ScanCode.NumpadAdd,155ScanCode.Numpad1,156ScanCode.Numpad2,157ScanCode.Numpad3,158ScanCode.Numpad4,159ScanCode.Numpad5,160ScanCode.Numpad6,161ScanCode.Numpad7,162ScanCode.Numpad8,163ScanCode.Numpad9,164ScanCode.Numpad0,165ScanCode.NumpadDecimal166];167168const otherMacNumpadMapping = new Map<ScanCode, KeyCode>();169otherMacNumpadMapping.set(ScanCode.Numpad1, KeyCode.Digit1);170otherMacNumpadMapping.set(ScanCode.Numpad2, KeyCode.Digit2);171otherMacNumpadMapping.set(ScanCode.Numpad3, KeyCode.Digit3);172otherMacNumpadMapping.set(ScanCode.Numpad4, KeyCode.Digit4);173otherMacNumpadMapping.set(ScanCode.Numpad5, KeyCode.Digit5);174otherMacNumpadMapping.set(ScanCode.Numpad6, KeyCode.Digit6);175otherMacNumpadMapping.set(ScanCode.Numpad7, KeyCode.Digit7);176otherMacNumpadMapping.set(ScanCode.Numpad8, KeyCode.Digit8);177otherMacNumpadMapping.set(ScanCode.Numpad9, KeyCode.Digit9);178otherMacNumpadMapping.set(ScanCode.Numpad0, KeyCode.Digit0);179180export class WorkbenchKeybindingService extends AbstractKeybindingService {181182private _keyboardMapper: IKeyboardMapper;183private _cachedResolver: KeybindingResolver | null;184private userKeybindings: UserKeybindings;185private isComposingGlobalContextKey: IContextKey<boolean>;186private _keybindingHoldMode: DeferredPromise<void> | null;187private readonly _contributions: Array<{188readonly listener?: IDisposable;189readonly contribution: KeybindingsSchemaContribution;190}> = [];191private readonly kbsJsonSchema: KeybindingsJsonSchema;192193constructor(194@IContextKeyService contextKeyService: IContextKeyService,195@ICommandService commandService: ICommandService,196@ITelemetryService telemetryService: ITelemetryService,197@INotificationService notificationService: INotificationService,198@IUserDataProfileService userDataProfileService: IUserDataProfileService,199@IHostService private readonly hostService: IHostService,200@IExtensionService extensionService: IExtensionService,201@IFileService fileService: IFileService,202@IUriIdentityService uriIdentityService: IUriIdentityService,203@ILogService logService: ILogService,204@IKeyboardLayoutService private readonly keyboardLayoutService: IKeyboardLayoutService205) {206super(contextKeyService, commandService, telemetryService, notificationService, logService);207208this.isComposingGlobalContextKey = contextKeyService.createKey('isComposing', false);209210this.kbsJsonSchema = new KeybindingsJsonSchema();211this.updateKeybindingsJsonSchema();212213this._keyboardMapper = this.keyboardLayoutService.getKeyboardMapper();214this._register(this.keyboardLayoutService.onDidChangeKeyboardLayout(() => {215this._keyboardMapper = this.keyboardLayoutService.getKeyboardMapper();216this.updateResolver();217}));218219this._keybindingHoldMode = null;220this._cachedResolver = null;221222this.userKeybindings = this._register(new UserKeybindings(userDataProfileService, uriIdentityService, fileService, logService));223this.userKeybindings.initialize().then(() => {224if (this.userKeybindings.keybindings.length) {225this.updateResolver();226}227});228this._register(this.userKeybindings.onDidChange(() => {229logService.debug('User keybindings changed');230this.updateResolver();231}));232233keybindingsExtPoint.setHandler((extensions) => {234235const keybindings: IExtensionKeybindingRule[] = [];236for (const extension of extensions) {237this._handleKeybindingsExtensionPointUser(extension.description.identifier, extension.description.isBuiltin, extension.value, extension.collector, keybindings);238}239240KeybindingsRegistry.setExtensionKeybindings(keybindings);241this.updateResolver();242});243244this.updateKeybindingsJsonSchema();245this._register(extensionService.onDidRegisterExtensions(() => this.updateKeybindingsJsonSchema()));246247this._register(Event.runAndSubscribe(dom.onDidRegisterWindow, ({ window, disposables }) => disposables.add(this._registerKeyListeners(window)), { window: mainWindow, disposables: this._store }));248249this._register(browser.onDidChangeFullscreen(windowId => {250if (windowId !== mainWindow.vscodeWindowId) {251return;252}253254const keyboard: IKeyboard | null = (<INavigatorWithKeyboard>navigator).keyboard;255256if (BrowserFeatures.keyboard === KeyboardSupport.None) {257return;258}259260if (browser.isFullscreen(mainWindow)) {261keyboard?.lock(['Escape']);262} else {263keyboard?.unlock();264}265266// update resolver which will bring back all unbound keyboard shortcuts267this._cachedResolver = null;268this._onDidUpdateKeybindings.fire();269}));270}271272public override dispose(): void {273this._contributions.forEach(c => c.listener?.dispose());274this._contributions.length = 0;275276super.dispose();277}278279private _registerKeyListeners(window: Window): IDisposable {280const disposables = new DisposableStore();281282// for standard keybindings283disposables.add(dom.addDisposableListener(window, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {284if (this._keybindingHoldMode) {285return;286}287this.isComposingGlobalContextKey.set(e.isComposing);288const keyEvent = new StandardKeyboardEvent(e);289this._log(`/ Received keydown event - ${printKeyboardEvent(e)}`);290this._log(`| Converted keydown event - ${printStandardKeyboardEvent(keyEvent)}`);291const shouldPreventDefault = this._dispatch(keyEvent, keyEvent.target);292if (shouldPreventDefault) {293keyEvent.preventDefault();294}295this.isComposingGlobalContextKey.set(false);296}));297298// for single modifier chord keybindings (e.g. shift shift)299disposables.add(dom.addDisposableListener(window, dom.EventType.KEY_UP, (e: KeyboardEvent) => {300this._resetKeybindingHoldMode();301this.isComposingGlobalContextKey.set(e.isComposing);302const keyEvent = new StandardKeyboardEvent(e);303const shouldPreventDefault = this._singleModifierDispatch(keyEvent, keyEvent.target);304if (shouldPreventDefault) {305keyEvent.preventDefault();306}307this.isComposingGlobalContextKey.set(false);308}));309310return disposables;311}312313public registerSchemaContribution(contribution: KeybindingsSchemaContribution): IDisposable {314const listener = contribution.onDidChange?.(() => this.updateKeybindingsJsonSchema());315const entry = { listener, contribution };316this._contributions.push(entry);317318this.updateKeybindingsJsonSchema();319320return toDisposable(() => {321listener?.dispose();322remove(this._contributions, entry);323this.updateKeybindingsJsonSchema();324});325}326327private updateKeybindingsJsonSchema() {328this.kbsJsonSchema.updateSchema(this._contributions.flatMap(x => x.contribution.getSchemaAdditions()));329}330331private _printKeybinding(keybinding: Keybinding): string {332return UserSettingsLabelProvider.toLabel(OS, keybinding.chords, (chord) => {333if (chord instanceof KeyCodeChord) {334return KeyCodeUtils.toString(chord.keyCode);335}336return ScanCodeUtils.toString(chord.scanCode);337}) || '[null]';338}339340private _printResolvedKeybinding(resolvedKeybinding: ResolvedKeybinding): string {341return resolvedKeybinding.getDispatchChords().map(x => x || '[null]').join(' ');342}343344private _printResolvedKeybindings(output: string[], input: string, resolvedKeybindings: ResolvedKeybinding[]): void {345const padLength = 35;346const firstRow = `${input.padStart(padLength, ' ')} => `;347if (resolvedKeybindings.length === 0) {348// no binding found349output.push(`${firstRow}${'[NO BINDING]'.padStart(padLength, ' ')}`);350return;351}352353const firstRowIndentation = firstRow.length;354const isFirst = true;355for (const resolvedKeybinding of resolvedKeybindings) {356if (isFirst) {357output.push(`${firstRow}${this._printResolvedKeybinding(resolvedKeybinding).padStart(padLength, ' ')}`);358} else {359output.push(`${' '.repeat(firstRowIndentation)}${this._printResolvedKeybinding(resolvedKeybinding).padStart(padLength, ' ')}`);360}361}362}363364private _dumpResolveKeybindingDebugInfo(): string {365366const seenBindings = new Set<string>();367const result: string[] = [];368369result.push(`Default Resolved Keybindings (unique only):`);370for (const item of KeybindingsRegistry.getDefaultKeybindings()) {371if (!item.keybinding) {372continue;373}374const input = this._printKeybinding(item.keybinding);375if (seenBindings.has(input)) {376continue;377}378seenBindings.add(input);379const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(item.keybinding);380this._printResolvedKeybindings(result, input, resolvedKeybindings);381}382383result.push(`User Resolved Keybindings (unique only):`);384for (const item of this.userKeybindings.keybindings) {385if (!item.keybinding) {386continue;387}388const input = item._sourceKey ?? 'Impossible: missing source key, but has keybinding';389if (seenBindings.has(input)) {390continue;391}392seenBindings.add(input);393const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(item.keybinding);394this._printResolvedKeybindings(result, input, resolvedKeybindings);395}396397return result.join('\n');398}399400public _dumpDebugInfo(): string {401const layoutInfo = JSON.stringify(this.keyboardLayoutService.getCurrentKeyboardLayout(), null, '\t');402const mapperInfo = this._keyboardMapper.dumpDebugInfo();403const resolvedKeybindings = this._dumpResolveKeybindingDebugInfo();404const rawMapping = JSON.stringify(this.keyboardLayoutService.getRawKeyboardMapping(), null, '\t');405return `Layout info:\n${layoutInfo}\n\n${resolvedKeybindings}\n\n${mapperInfo}\n\nRaw mapping:\n${rawMapping}`;406}407408public _dumpDebugInfoJSON(): string {409const info = {410layout: this.keyboardLayoutService.getCurrentKeyboardLayout(),411rawMapping: this.keyboardLayoutService.getRawKeyboardMapping()412};413return JSON.stringify(info, null, '\t');414}415416public override enableKeybindingHoldMode(commandId: string): Promise<void> | undefined {417if (this._currentlyDispatchingCommandId !== commandId) {418return undefined;419}420this._keybindingHoldMode = new DeferredPromise<void>();421const focusTracker = dom.trackFocus(dom.getWindow(undefined));422const listener = focusTracker.onDidBlur(() => this._resetKeybindingHoldMode());423this._keybindingHoldMode.p.finally(() => {424listener.dispose();425focusTracker.dispose();426});427this._log(`+ Enabled hold-mode for ${commandId}.`);428return this._keybindingHoldMode.p;429}430431private _resetKeybindingHoldMode(): void {432if (this._keybindingHoldMode) {433this._keybindingHoldMode?.complete();434this._keybindingHoldMode = null;435}436}437438public override customKeybindingsCount(): number {439return this.userKeybindings.keybindings.length;440}441442private updateResolver(): void {443this._cachedResolver = null;444this._onDidUpdateKeybindings.fire();445}446447protected _getResolver(): KeybindingResolver {448if (!this._cachedResolver) {449const defaults = this._resolveKeybindingItems(KeybindingsRegistry.getDefaultKeybindings(), true);450const overrides = this._resolveUserKeybindingItems(this.userKeybindings.keybindings, false);451this._cachedResolver = new KeybindingResolver(defaults, overrides, (str) => this._log(str));452}453return this._cachedResolver;454}455456protected _documentHasFocus(): boolean {457// it is possible that the document has lost focus, but the458// window is still focused, e.g. when a <webview> element459// has focus460return this.hostService.hasFocus;461}462463private _resolveKeybindingItems(items: IKeybindingItem[], isDefault: boolean): ResolvedKeybindingItem[] {464const result: ResolvedKeybindingItem[] = [];465let resultLen = 0;466for (const item of items) {467const when = item.when || undefined;468const keybinding = item.keybinding;469if (!keybinding) {470// This might be a removal keybinding item in user settings => accept it471result[resultLen++] = new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, isDefault, item.extensionId, item.isBuiltinExtension);472} else {473if (this._assertBrowserConflicts(keybinding)) {474continue;475}476477const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(keybinding);478for (let i = resolvedKeybindings.length - 1; i >= 0; i--) {479const resolvedKeybinding = resolvedKeybindings[i];480result[resultLen++] = new ResolvedKeybindingItem(resolvedKeybinding, item.command, item.commandArgs, when, isDefault, item.extensionId, item.isBuiltinExtension);481}482}483}484485return result;486}487488private _resolveUserKeybindingItems(items: IUserKeybindingItem[], isDefault: boolean): ResolvedKeybindingItem[] {489const result: ResolvedKeybindingItem[] = [];490let resultLen = 0;491for (const item of items) {492const when = item.when || undefined;493if (!item.keybinding) {494// This might be a removal keybinding item in user settings => accept it495result[resultLen++] = new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, isDefault, null, false);496} else {497const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(item.keybinding);498for (const resolvedKeybinding of resolvedKeybindings) {499result[resultLen++] = new ResolvedKeybindingItem(resolvedKeybinding, item.command, item.commandArgs, when, isDefault, null, false);500}501}502}503504return result;505}506507private _assertBrowserConflicts(keybinding: Keybinding): boolean {508if (BrowserFeatures.keyboard === KeyboardSupport.Always) {509return false;510}511512if (BrowserFeatures.keyboard === KeyboardSupport.FullScreen && browser.isFullscreen(mainWindow)) {513return false;514}515516for (const chord of keybinding.chords) {517if (!chord.metaKey && !chord.altKey && !chord.ctrlKey && !chord.shiftKey) {518continue;519}520521const modifiersMask = KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift;522523let partModifiersMask = 0;524if (chord.metaKey) {525partModifiersMask |= KeyMod.CtrlCmd;526}527528if (chord.shiftKey) {529partModifiersMask |= KeyMod.Shift;530}531532if (chord.altKey) {533partModifiersMask |= KeyMod.Alt;534}535536if (chord.ctrlKey && OS === OperatingSystem.Macintosh) {537partModifiersMask |= KeyMod.WinCtrl;538}539540if ((partModifiersMask & modifiersMask) === (KeyMod.CtrlCmd | KeyMod.Alt)) {541if (chord instanceof ScanCodeChord && (chord.scanCode === ScanCode.ArrowLeft || chord.scanCode === ScanCode.ArrowRight)) {542// console.warn('Ctrl/Cmd+Arrow keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);543return true;544}545if (chord instanceof KeyCodeChord && (chord.keyCode === KeyCode.LeftArrow || chord.keyCode === KeyCode.RightArrow)) {546// console.warn('Ctrl/Cmd+Arrow keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);547return true;548}549}550551if ((partModifiersMask & modifiersMask) === KeyMod.CtrlCmd) {552if (chord instanceof ScanCodeChord && (chord.scanCode >= ScanCode.Digit1 && chord.scanCode <= ScanCode.Digit0)) {553// console.warn('Ctrl/Cmd+Num keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);554return true;555}556if (chord instanceof KeyCodeChord && (chord.keyCode >= KeyCode.Digit0 && chord.keyCode <= KeyCode.Digit9)) {557// console.warn('Ctrl/Cmd+Num keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);558return true;559}560}561}562563return false;564}565566public resolveKeybinding(kb: Keybinding): ResolvedKeybinding[] {567return this._keyboardMapper.resolveKeybinding(kb);568}569570public resolveKeyboardEvent(keyboardEvent: IKeyboardEvent): ResolvedKeybinding {571this.keyboardLayoutService.validateCurrentKeyboardMapping(keyboardEvent);572return this._keyboardMapper.resolveKeyboardEvent(keyboardEvent);573}574575public resolveUserBinding(userBinding: string): ResolvedKeybinding[] {576const keybinding = KeybindingParser.parseKeybinding(userBinding);577return (keybinding ? this._keyboardMapper.resolveKeybinding(keybinding) : []);578}579580private _handleKeybindingsExtensionPointUser(extensionId: ExtensionIdentifier, isBuiltin: boolean, keybindings: ContributedKeyBinding | ContributedKeyBinding[], collector: ExtensionMessageCollector, result: IExtensionKeybindingRule[]): void {581if (Array.isArray(keybindings)) {582for (let i = 0, len = keybindings.length; i < len; i++) {583this._handleKeybinding(extensionId, isBuiltin, i + 1, keybindings[i], collector, result);584}585} else {586this._handleKeybinding(extensionId, isBuiltin, 1, keybindings, collector, result);587}588}589590private _handleKeybinding(extensionId: ExtensionIdentifier, isBuiltin: boolean, idx: number, keybindings: ContributedKeyBinding, collector: ExtensionMessageCollector, result: IExtensionKeybindingRule[]): void {591592const rejects: string[] = [];593594if (isValidContributedKeyBinding(keybindings, rejects)) {595const rule = this._asCommandRule(extensionId, isBuiltin, idx++, keybindings);596if (rule) {597result.push(rule);598}599}600601if (rejects.length > 0) {602collector.error(nls.localize(603'invalid.keybindings',604"Invalid `contributes.{0}`: {1}",605keybindingsExtPoint.name,606rejects.join('\n')607));608}609}610611private static bindToCurrentPlatform(key: string | undefined, mac: string | undefined, linux: string | undefined, win: string | undefined): string | undefined {612if (OS === OperatingSystem.Windows && win) {613if (win) {614return win;615}616} else if (OS === OperatingSystem.Macintosh) {617if (mac) {618return mac;619}620} else {621if (linux) {622return linux;623}624}625return key;626}627628private _asCommandRule(extensionId: ExtensionIdentifier, isBuiltin: boolean, idx: number, binding: ContributedKeyBinding): IExtensionKeybindingRule | undefined {629630const { command, args, when, key, mac, linux, win } = binding;631const keybinding = WorkbenchKeybindingService.bindToCurrentPlatform(key, mac, linux, win);632if (!keybinding) {633return undefined;634}635636let weight: number;637if (isBuiltin) {638weight = KeybindingWeight.BuiltinExtension + idx;639} else {640weight = KeybindingWeight.ExternalExtension + idx;641}642643const commandAction = MenuRegistry.getCommand(command);644const precondition = commandAction && commandAction.precondition;645let fullWhen: ContextKeyExpression | undefined;646if (when && precondition) {647fullWhen = ContextKeyExpr.and(precondition, ContextKeyExpr.deserialize(when));648} else if (when) {649fullWhen = ContextKeyExpr.deserialize(when);650} else if (precondition) {651fullWhen = precondition;652}653654const desc: IExtensionKeybindingRule = {655id: command,656args,657when: fullWhen,658weight: weight,659keybinding: KeybindingParser.parseKeybinding(keybinding),660extensionId: extensionId.value,661isBuiltinExtension: isBuiltin662};663return desc;664}665666public override getDefaultKeybindingsContent(): string {667const resolver = this._getResolver();668const defaultKeybindings = resolver.getDefaultKeybindings();669const boundCommands = resolver.getDefaultBoundCommands();670return (671WorkbenchKeybindingService._getDefaultKeybindings(defaultKeybindings)672+ '\n\n'673+ WorkbenchKeybindingService._getAllCommandsAsComment(boundCommands)674);675}676677private static _getDefaultKeybindings(defaultKeybindings: readonly ResolvedKeybindingItem[]): string {678const out = new OutputBuilder();679out.writeLine('[');680681const lastIndex = defaultKeybindings.length - 1;682defaultKeybindings.forEach((k, index) => {683KeybindingIO.writeKeybindingItem(out, k);684if (index !== lastIndex) {685out.writeLine(',');686} else {687out.writeLine();688}689});690out.writeLine(']');691return out.toString();692}693694private static _getAllCommandsAsComment(boundCommands: Map<string, boolean>): string {695const unboundCommands = getAllUnboundCommands(boundCommands);696const pretty = unboundCommands.sort().join('\n// - ');697return '// ' + nls.localize('unboundCommands', "Here are other available commands: ") + '\n// - ' + pretty;698}699700override mightProducePrintableCharacter(event: IKeyboardEvent): boolean {701if (event.ctrlKey || event.metaKey || event.altKey) {702// ignore ctrl/cmd/alt-combination but not shift-combinatios703return false;704}705const code = ScanCodeUtils.toEnum(event.code);706707if (NUMPAD_PRINTABLE_SCANCODES.indexOf(code) !== -1) {708// This is a numpad key that might produce a printable character based on NumLock.709// Let's check if NumLock is on or off based on the event's keyCode.710// e.g.711// - when NumLock is off, ScanCode.Numpad4 produces KeyCode.LeftArrow712// - when NumLock is on, ScanCode.Numpad4 produces KeyCode.NUMPAD_4713// However, ScanCode.NumpadAdd always produces KeyCode.NUMPAD_ADD714if (event.keyCode === IMMUTABLE_CODE_TO_KEY_CODE[code]) {715// NumLock is on or this is /, *, -, + on the numpad716return true;717}718if (isMacintosh && event.keyCode === otherMacNumpadMapping.get(code)) {719// on macOS, the numpad keys can also map to keys 1 - 0.720return true;721}722return false;723}724725const keycode = IMMUTABLE_CODE_TO_KEY_CODE[code];726if (keycode !== -1) {727// https://github.com/microsoft/vscode/issues/74934728return false;729}730// consult the KeyboardMapperFactory to check the given event for731// a printable value.732const mapping = this.keyboardLayoutService.getRawKeyboardMapping();733if (!mapping) {734return false;735}736const keyInfo = mapping[event.code];737if (!keyInfo) {738return false;739}740if (!keyInfo.value || /\s/.test(keyInfo.value)) {741return false;742}743return true;744}745}746747class UserKeybindings extends Disposable {748749private _rawKeybindings: Object[] = [];750private _keybindings: IUserKeybindingItem[] = [];751get keybindings(): IUserKeybindingItem[] { return this._keybindings; }752753private readonly reloadConfigurationScheduler: RunOnceScheduler;754755private readonly watchDisposables = this._register(new DisposableStore());756757private readonly _onDidChange: Emitter<void> = this._register(new Emitter<void>());758readonly onDidChange: Event<void> = this._onDidChange.event;759760constructor(761private readonly userDataProfileService: IUserDataProfileService,762private readonly uriIdentityService: IUriIdentityService,763private readonly fileService: IFileService,764logService: ILogService,765) {766super();767768this.watch();769770this.reloadConfigurationScheduler = this._register(new RunOnceScheduler(() => this.reload().then(changed => {771if (changed) {772this._onDidChange.fire();773}774}), 50));775776this._register(Event.filter(this.fileService.onDidFilesChange, e => e.contains(this.userDataProfileService.currentProfile.keybindingsResource))(() => {777logService.debug('Keybindings file changed');778this.reloadConfigurationScheduler.schedule();779}));780781this._register(this.fileService.onDidRunOperation((e) => {782if (e.operation === FileOperation.WRITE && e.resource.toString() === this.userDataProfileService.currentProfile.keybindingsResource.toString()) {783logService.debug('Keybindings file written');784this.reloadConfigurationScheduler.schedule();785}786}));787788this._register(userDataProfileService.onDidChangeCurrentProfile(e => {789if (!this.uriIdentityService.extUri.isEqual(e.previous.keybindingsResource, e.profile.keybindingsResource)) {790e.join(this.whenCurrentProfileChanged());791}792}));793}794795private async whenCurrentProfileChanged(): Promise<void> {796this.watch();797this.reloadConfigurationScheduler.schedule();798}799800private watch(): void {801this.watchDisposables.clear();802this.watchDisposables.add(this.fileService.watch(dirname(this.userDataProfileService.currentProfile.keybindingsResource)));803// Also listen to the resource incase the resource is a symlink - https://github.com/microsoft/vscode/issues/118134804this.watchDisposables.add(this.fileService.watch(this.userDataProfileService.currentProfile.keybindingsResource));805}806807async initialize(): Promise<void> {808await this.reload();809}810811private async reload(): Promise<boolean> {812const newKeybindings = await this.readUserKeybindings();813if (objects.equals(this._rawKeybindings, newKeybindings)) {814// no change815return false;816}817818this._rawKeybindings = newKeybindings;819this._keybindings = this._rawKeybindings.map((k) => KeybindingIO.readUserKeybindingItem(k));820return true;821}822823private async readUserKeybindings(): Promise<Object[]> {824try {825const content = await this.fileService.readFile(this.userDataProfileService.currentProfile.keybindingsResource);826const value = parse(content.value.toString());827return Array.isArray(value)828? value.filter(v => v && typeof v === 'object' /* just typeof === object doesn't catch `null` */)829: [];830} catch (e) {831return [];832}833}834}835836/**837* Registers the `keybindings.json`'s schema with the JSON schema registry. Allows updating the schema, e.g., when new commands are registered (e.g., by extensions).838*839* Lifecycle owned by `WorkbenchKeybindingService`. Must be instantiated only once.840*/841class KeybindingsJsonSchema {842843private static readonly schemaId = 'vscode://schemas/keybindings';844845private readonly commandsSchemas: IJSONSchema[] = [];846private readonly commandsEnum: string[] = [];847private readonly removalCommandsEnum: string[] = [];848private readonly commandsEnumDescriptions: (string | undefined)[] = [];849private readonly schema: IJSONSchema = {850id: KeybindingsJsonSchema.schemaId,851type: 'array',852title: nls.localize('keybindings.json.title', "Keybindings configuration"),853allowTrailingCommas: true,854allowComments: true,855definitions: {856'editorGroupsSchema': {857'type': 'array',858'items': {859'type': 'object',860'properties': {861'groups': {862'$ref': '#/definitions/editorGroupsSchema',863'default': [{}, {}]864},865'size': {866'type': 'number',867'default': 0.5868}869}870}871},872'commandNames': {873'type': 'string',874'enum': this.commandsEnum,875'enumDescriptions': <any>this.commandsEnumDescriptions,876'description': nls.localize('keybindings.json.command', "Name of the command to execute"),877},878'commandType': {879'anyOf': [ // repetition of this clause here and below is intentional: one is for nice diagnostics & one is for code completion880{881$ref: '#/definitions/commandNames'882},883{884'type': 'string',885'enum': this.removalCommandsEnum,886'enumDescriptions': <any>this.commandsEnumDescriptions,887'description': nls.localize('keybindings.json.removalCommand', "Name of the command to remove keyboard shortcut for"),888},889{890'type': 'string'891},892]893},894'commandsSchemas': {895'allOf': this.commandsSchemas896}897},898items: {899'required': ['key'],900'type': 'object',901'defaultSnippets': [{ 'body': { 'key': '$1', 'command': '$2', 'when': '$3' } }],902'properties': {903'key': {904'type': 'string',905'description': nls.localize('keybindings.json.key', "Key or key sequence (separated by space)"),906},907'command': {908'anyOf': [909{910'if': {911'type': 'array'912},913'then': {914'not': {915'type': 'array'916},917'errorMessage': nls.localize('keybindings.commandsIsArray', "Incorrect type. Expected \"{0}\". The field 'command' does not support running multiple commands. Use command 'runCommands' to pass it multiple commands to run.", 'string')918},919'else': {920'$ref': '#/definitions/commandType'921}922},923{924'$ref': '#/definitions/commandType'925}926]927},928'when': {929'type': 'string',930'description': nls.localize('keybindings.json.when', "Condition when the key is active.")931},932'args': {933'description': nls.localize('keybindings.json.args', "Arguments to pass to the command to execute.")934}935},936'$ref': '#/definitions/commandsSchemas'937}938};939940private readonly schemaRegistry = Registry.as<IJSONContributionRegistry>(Extensions.JSONContribution);941942constructor() {943this.schemaRegistry.registerSchema(KeybindingsJsonSchema.schemaId, this.schema);944}945946// TODO@ulugbekna: can updates happen incrementally rather than rebuilding; concerns:947// - is just appending additional schemas enough for the registry to pick them up?948// - can `CommandsRegistry.getCommands` and `MenuRegistry.getCommands` return different values at different times? ie would just pushing new schemas from `additionalContributions` not be enough?949updateSchema(additionalContributions: readonly IJSONSchema[]) {950this.commandsSchemas.length = 0;951this.commandsEnum.length = 0;952this.removalCommandsEnum.length = 0;953this.commandsEnumDescriptions.length = 0;954955const knownCommands = new Set<string>();956const addKnownCommand = (commandId: string, description?: string | ILocalizedString | undefined) => {957if (!/^_/.test(commandId)) {958if (!knownCommands.has(commandId)) {959knownCommands.add(commandId);960961this.commandsEnum.push(commandId);962this.commandsEnumDescriptions.push(isLocalizedString(description) ? description.value : description);963964// Also add the negative form for keybinding removal965this.removalCommandsEnum.push(`-${commandId}`);966}967}968};969970const allCommands = CommandsRegistry.getCommands();971for (const [commandId, command] of allCommands) {972const commandMetadata = command.metadata;973974addKnownCommand(commandId, commandMetadata?.description ?? MenuRegistry.getCommand(commandId)?.title);975976if (!commandMetadata || !commandMetadata.args || commandMetadata.args.length !== 1 || !commandMetadata.args[0].schema) {977continue;978}979980const argsSchema = commandMetadata.args[0].schema;981const argsRequired = (982(typeof commandMetadata.args[0].isOptional !== 'undefined')983? (!commandMetadata.args[0].isOptional)984: (Array.isArray(argsSchema.required) && argsSchema.required.length > 0)985);986const addition = {987'if': {988'required': ['command'],989'properties': {990'command': { 'const': commandId }991}992},993'then': {994'required': (<string[]>[]).concat(argsRequired ? ['args'] : []),995'properties': {996'args': argsSchema997}998}999};10001001this.commandsSchemas.push(addition);1002}10031004const menuCommands = MenuRegistry.getCommands();1005for (const commandId of menuCommands.keys()) {1006addKnownCommand(commandId);1007}10081009this.commandsSchemas.push(...additionalContributions);1010this.schemaRegistry.notifySchemaChanged(KeybindingsJsonSchema.schemaId);1011}1012}10131014registerSingleton(IKeybindingService, WorkbenchKeybindingService, InstantiationType.Eager);101510161017