Path: blob/main/src/vs/workbench/services/keybinding/browser/keybindingService.ts
5221 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, TypeFromJsonSchema } 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';58import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';5960function isValidContributedKeyBinding(keyBinding: ContributedKeyBinding, rejects: string[]): boolean {61if (!keyBinding) {62rejects.push(nls.localize('nonempty', "expected non-empty value."));63return false;64}65if (typeof keyBinding.command !== 'string') {66rejects.push(nls.localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command'));67return false;68}69if (keyBinding.key && typeof keyBinding.key !== 'string') {70rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'key'));71return false;72}73if (keyBinding.when && typeof keyBinding.when !== 'string') {74rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when'));75return false;76}77if (keyBinding.mac && typeof keyBinding.mac !== 'string') {78rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'mac'));79return false;80}81if (keyBinding.linux && typeof keyBinding.linux !== 'string') {82rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'linux'));83return false;84}85if (keyBinding.win && typeof keyBinding.win !== 'string') {86rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'win'));87return false;88}89return true;90}9192const keybindingType = {93type: 'object',94default: { command: '', key: '' },95required: ['command', 'key'],96properties: {97command: {98description: nls.localize('vscode.extension.contributes.keybindings.command', 'Identifier of the command to run when keybinding is triggered.'),99type: 'string'100},101args: {102description: nls.localize('vscode.extension.contributes.keybindings.args', "Arguments to pass to the command to execute.")103},104key: {105description: 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).'),106type: 'string'107},108mac: {109description: nls.localize('vscode.extension.contributes.keybindings.mac', 'Mac specific key or key sequence.'),110type: 'string'111},112linux: {113description: nls.localize('vscode.extension.contributes.keybindings.linux', 'Linux specific key or key sequence.'),114type: 'string'115},116win: {117description: nls.localize('vscode.extension.contributes.keybindings.win', 'Windows specific key or key sequence.'),118type: 'string'119},120when: {121description: nls.localize('vscode.extension.contributes.keybindings.when', 'Condition when the key is active.'),122type: 'string'123},124}125} as const satisfies IJSONSchema;126127type ContributedKeyBinding = TypeFromJsonSchema<typeof keybindingType>;128129const keybindingsExtPoint = ExtensionsRegistry.registerExtensionPoint<ContributedKeyBinding | ContributedKeyBinding[]>({130extensionPoint: 'keybindings',131deps: [commandsExtensionPoint],132jsonSchema: {133description: nls.localize('vscode.extension.contributes.keybindings', "Contributes keybindings."),134oneOf: [135keybindingType,136{137type: 'array',138items: keybindingType139}140]141}142});143144const NUMPAD_PRINTABLE_SCANCODES = [145ScanCode.NumpadDivide,146ScanCode.NumpadMultiply,147ScanCode.NumpadSubtract,148ScanCode.NumpadAdd,149ScanCode.Numpad1,150ScanCode.Numpad2,151ScanCode.Numpad3,152ScanCode.Numpad4,153ScanCode.Numpad5,154ScanCode.Numpad6,155ScanCode.Numpad7,156ScanCode.Numpad8,157ScanCode.Numpad9,158ScanCode.Numpad0,159ScanCode.NumpadDecimal160];161162const otherMacNumpadMapping = new Map<ScanCode, KeyCode>();163otherMacNumpadMapping.set(ScanCode.Numpad1, KeyCode.Digit1);164otherMacNumpadMapping.set(ScanCode.Numpad2, KeyCode.Digit2);165otherMacNumpadMapping.set(ScanCode.Numpad3, KeyCode.Digit3);166otherMacNumpadMapping.set(ScanCode.Numpad4, KeyCode.Digit4);167otherMacNumpadMapping.set(ScanCode.Numpad5, KeyCode.Digit5);168otherMacNumpadMapping.set(ScanCode.Numpad6, KeyCode.Digit6);169otherMacNumpadMapping.set(ScanCode.Numpad7, KeyCode.Digit7);170otherMacNumpadMapping.set(ScanCode.Numpad8, KeyCode.Digit8);171otherMacNumpadMapping.set(ScanCode.Numpad9, KeyCode.Digit9);172otherMacNumpadMapping.set(ScanCode.Numpad0, KeyCode.Digit0);173174export class WorkbenchKeybindingService extends AbstractKeybindingService {175176private _keyboardMapper: IKeyboardMapper;177private _cachedResolver: KeybindingResolver | null;178private userKeybindings: UserKeybindings;179private isComposingGlobalContextKey: IContextKey<boolean>;180private _keybindingHoldMode: DeferredPromise<void> | null;181private readonly _contributions: Array<{182readonly listener?: IDisposable;183readonly contribution: KeybindingsSchemaContribution;184}> = [];185private readonly kbsJsonSchema: KeybindingsJsonSchema;186187constructor(188@IContextKeyService contextKeyService: IContextKeyService,189@ICommandService commandService: ICommandService,190@ITelemetryService telemetryService: ITelemetryService,191@INotificationService notificationService: INotificationService,192@IUserDataProfileService userDataProfileService: IUserDataProfileService,193@IHostService private readonly hostService: IHostService,194@IExtensionService extensionService: IExtensionService,195@IFileService fileService: IFileService,196@IUriIdentityService uriIdentityService: IUriIdentityService,197@ILogService logService: ILogService,198@IKeyboardLayoutService private readonly keyboardLayoutService: IKeyboardLayoutService199) {200super(contextKeyService, commandService, telemetryService, notificationService, logService);201202this.isComposingGlobalContextKey = contextKeyService.createKey(EditorContextKeys.isComposing.key, false);203204this.kbsJsonSchema = new KeybindingsJsonSchema();205this.updateKeybindingsJsonSchema();206207this._keyboardMapper = this.keyboardLayoutService.getKeyboardMapper();208this._register(this.keyboardLayoutService.onDidChangeKeyboardLayout(() => {209this._keyboardMapper = this.keyboardLayoutService.getKeyboardMapper();210this.updateResolver();211}));212213this._keybindingHoldMode = null;214this._cachedResolver = null;215216this.userKeybindings = this._register(new UserKeybindings(userDataProfileService, uriIdentityService, fileService, logService));217this.userKeybindings.initialize().then(() => {218if (this.userKeybindings.keybindings.length) {219this.updateResolver();220}221});222this._register(this.userKeybindings.onDidChange(() => {223logService.debug('User keybindings changed');224this.updateResolver();225}));226227keybindingsExtPoint.setHandler((extensions) => {228229const keybindings: IExtensionKeybindingRule[] = [];230for (const extension of extensions) {231this._handleKeybindingsExtensionPointUser(extension.description.identifier, extension.description.isBuiltin, extension.value, extension.collector, keybindings);232}233234KeybindingsRegistry.setExtensionKeybindings(keybindings);235this.updateResolver();236});237238this.updateKeybindingsJsonSchema();239this._register(extensionService.onDidRegisterExtensions(() => this.updateKeybindingsJsonSchema()));240241this._register(Event.runAndSubscribe(dom.onDidRegisterWindow, ({ window, disposables }) => disposables.add(this._registerKeyListeners(window)), { window: mainWindow, disposables: this._store }));242243this._register(browser.onDidChangeFullscreen(windowId => {244if (windowId !== mainWindow.vscodeWindowId) {245return;246}247248const keyboard: IKeyboard | null = (<INavigatorWithKeyboard>navigator).keyboard;249250if (BrowserFeatures.keyboard === KeyboardSupport.None) {251return;252}253254if (browser.isFullscreen(mainWindow)) {255keyboard?.lock(['Escape']);256} else {257keyboard?.unlock();258}259260// update resolver which will bring back all unbound keyboard shortcuts261this._cachedResolver = null;262this._onDidUpdateKeybindings.fire();263}));264}265266public override dispose(): void {267this._contributions.forEach(c => c.listener?.dispose());268this._contributions.length = 0;269270super.dispose();271}272273private _registerKeyListeners(window: Window): IDisposable {274const disposables = new DisposableStore();275276// for standard keybindings277disposables.add(dom.addDisposableListener(window, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {278if (this._keybindingHoldMode) {279return;280}281this.isComposingGlobalContextKey.set(e.isComposing);282const keyEvent = new StandardKeyboardEvent(e);283this._log(`/ Received keydown event - ${printKeyboardEvent(e)}`);284this._log(`| Converted keydown event - ${printStandardKeyboardEvent(keyEvent)}`);285const shouldPreventDefault = this._dispatch(keyEvent, keyEvent.target);286if (shouldPreventDefault) {287keyEvent.preventDefault();288}289this.isComposingGlobalContextKey.set(false);290}));291292// for single modifier chord keybindings (e.g. shift shift)293disposables.add(dom.addDisposableListener(window, dom.EventType.KEY_UP, (e: KeyboardEvent) => {294this._resetKeybindingHoldMode();295this.isComposingGlobalContextKey.set(e.isComposing);296const keyEvent = new StandardKeyboardEvent(e);297const shouldPreventDefault = this._singleModifierDispatch(keyEvent, keyEvent.target);298if (shouldPreventDefault) {299keyEvent.preventDefault();300}301this.isComposingGlobalContextKey.set(false);302}));303304return disposables;305}306307public registerSchemaContribution(contribution: KeybindingsSchemaContribution): IDisposable {308const listener = contribution.onDidChange?.(() => this.updateKeybindingsJsonSchema());309const entry = { listener, contribution };310this._contributions.push(entry);311312this.updateKeybindingsJsonSchema();313314return toDisposable(() => {315listener?.dispose();316remove(this._contributions, entry);317this.updateKeybindingsJsonSchema();318});319}320321private updateKeybindingsJsonSchema() {322this.kbsJsonSchema.updateSchema(this._contributions.flatMap(x => x.contribution.getSchemaAdditions()));323}324325private _printKeybinding(keybinding: Keybinding): string {326return UserSettingsLabelProvider.toLabel(OS, keybinding.chords, (chord) => {327if (chord instanceof KeyCodeChord) {328return KeyCodeUtils.toString(chord.keyCode);329}330return ScanCodeUtils.toString(chord.scanCode);331}) || '[null]';332}333334private _printResolvedKeybinding(resolvedKeybinding: ResolvedKeybinding): string {335return resolvedKeybinding.getDispatchChords().map(x => x || '[null]').join(' ');336}337338private _printResolvedKeybindings(output: string[], input: string, resolvedKeybindings: ResolvedKeybinding[]): void {339const padLength = 35;340const firstRow = `${input.padStart(padLength, ' ')} => `;341if (resolvedKeybindings.length === 0) {342// no binding found343output.push(`${firstRow}${'[NO BINDING]'.padStart(padLength, ' ')}`);344return;345}346347const firstRowIndentation = firstRow.length;348const isFirst = true;349for (const resolvedKeybinding of resolvedKeybindings) {350if (isFirst) {351output.push(`${firstRow}${this._printResolvedKeybinding(resolvedKeybinding).padStart(padLength, ' ')}`);352} else {353output.push(`${' '.repeat(firstRowIndentation)}${this._printResolvedKeybinding(resolvedKeybinding).padStart(padLength, ' ')}`);354}355}356}357358private _dumpResolveKeybindingDebugInfo(): string {359360const seenBindings = new Set<string>();361const result: string[] = [];362363result.push(`Default Resolved Keybindings (unique only):`);364for (const item of KeybindingsRegistry.getDefaultKeybindings()) {365if (!item.keybinding) {366continue;367}368const input = this._printKeybinding(item.keybinding);369if (seenBindings.has(input)) {370continue;371}372seenBindings.add(input);373const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(item.keybinding);374this._printResolvedKeybindings(result, input, resolvedKeybindings);375}376377result.push(`User Resolved Keybindings (unique only):`);378for (const item of this.userKeybindings.keybindings) {379if (!item.keybinding) {380continue;381}382const input = item._sourceKey ?? 'Impossible: missing source key, but has keybinding';383if (seenBindings.has(input)) {384continue;385}386seenBindings.add(input);387const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(item.keybinding);388this._printResolvedKeybindings(result, input, resolvedKeybindings);389}390391return result.join('\n');392}393394public _dumpDebugInfo(): string {395const layoutInfo = JSON.stringify(this.keyboardLayoutService.getCurrentKeyboardLayout(), null, '\t');396const mapperInfo = this._keyboardMapper.dumpDebugInfo();397const resolvedKeybindings = this._dumpResolveKeybindingDebugInfo();398const rawMapping = JSON.stringify(this.keyboardLayoutService.getRawKeyboardMapping(), null, '\t');399return `Layout info:\n${layoutInfo}\n\n${resolvedKeybindings}\n\n${mapperInfo}\n\nRaw mapping:\n${rawMapping}`;400}401402public _dumpDebugInfoJSON(): string {403const info = {404layout: this.keyboardLayoutService.getCurrentKeyboardLayout(),405rawMapping: this.keyboardLayoutService.getRawKeyboardMapping()406};407return JSON.stringify(info, null, '\t');408}409410public override enableKeybindingHoldMode(commandId: string): Promise<void> | undefined {411if (this._currentlyDispatchingCommandId !== commandId) {412return undefined;413}414this._keybindingHoldMode = new DeferredPromise<void>();415const focusTracker = dom.trackFocus(dom.getWindow(undefined));416const listener = focusTracker.onDidBlur(() => this._resetKeybindingHoldMode());417this._keybindingHoldMode.p.finally(() => {418listener.dispose();419focusTracker.dispose();420});421this._log(`+ Enabled hold-mode for ${commandId}.`);422return this._keybindingHoldMode.p;423}424425private _resetKeybindingHoldMode(): void {426if (this._keybindingHoldMode) {427this._keybindingHoldMode?.complete();428this._keybindingHoldMode = null;429}430}431432public override customKeybindingsCount(): number {433return this.userKeybindings.keybindings.length;434}435436private updateResolver(): void {437this._cachedResolver = null;438this._onDidUpdateKeybindings.fire();439}440441protected _getResolver(): KeybindingResolver {442if (!this._cachedResolver) {443const defaults = this._resolveKeybindingItems(KeybindingsRegistry.getDefaultKeybindings(), true);444const overrides = this._resolveUserKeybindingItems(this.userKeybindings.keybindings, false);445this._cachedResolver = new KeybindingResolver(defaults, overrides, (str) => this._log(str));446}447return this._cachedResolver;448}449450protected _documentHasFocus(): boolean {451// it is possible that the document has lost focus, but the452// window is still focused, e.g. when a <webview> element453// has focus454return this.hostService.hasFocus;455}456457private _resolveKeybindingItems(items: IKeybindingItem[], isDefault: boolean): ResolvedKeybindingItem[] {458const result: ResolvedKeybindingItem[] = [];459let resultLen = 0;460for (const item of items) {461const when = item.when || undefined;462const keybinding = item.keybinding;463if (!keybinding) {464// This might be a removal keybinding item in user settings => accept it465result[resultLen++] = new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, isDefault, item.extensionId, item.isBuiltinExtension);466} else {467if (this._assertBrowserConflicts(keybinding)) {468continue;469}470471const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(keybinding);472for (let i = resolvedKeybindings.length - 1; i >= 0; i--) {473const resolvedKeybinding = resolvedKeybindings[i];474result[resultLen++] = new ResolvedKeybindingItem(resolvedKeybinding, item.command, item.commandArgs, when, isDefault, item.extensionId, item.isBuiltinExtension);475}476}477}478479return result;480}481482private _resolveUserKeybindingItems(items: IUserKeybindingItem[], isDefault: boolean): ResolvedKeybindingItem[] {483const result: ResolvedKeybindingItem[] = [];484let resultLen = 0;485for (const item of items) {486const when = item.when || undefined;487if (!item.keybinding) {488// This might be a removal keybinding item in user settings => accept it489result[resultLen++] = new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, isDefault, null, false);490} else {491const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(item.keybinding);492for (const resolvedKeybinding of resolvedKeybindings) {493result[resultLen++] = new ResolvedKeybindingItem(resolvedKeybinding, item.command, item.commandArgs, when, isDefault, null, false);494}495}496}497498return result;499}500501private _assertBrowserConflicts(keybinding: Keybinding): boolean {502if (BrowserFeatures.keyboard === KeyboardSupport.Always) {503return false;504}505506if (BrowserFeatures.keyboard === KeyboardSupport.FullScreen && browser.isFullscreen(mainWindow)) {507return false;508}509510for (const chord of keybinding.chords) {511if (!chord.metaKey && !chord.altKey && !chord.ctrlKey && !chord.shiftKey) {512continue;513}514515const modifiersMask = KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift;516517let partModifiersMask = 0;518if (chord.metaKey) {519partModifiersMask |= KeyMod.CtrlCmd;520}521522if (chord.shiftKey) {523partModifiersMask |= KeyMod.Shift;524}525526if (chord.altKey) {527partModifiersMask |= KeyMod.Alt;528}529530if (chord.ctrlKey && OS === OperatingSystem.Macintosh) {531partModifiersMask |= KeyMod.WinCtrl;532}533534if ((partModifiersMask & modifiersMask) === (KeyMod.CtrlCmd | KeyMod.Alt)) {535if (chord instanceof ScanCodeChord && (chord.scanCode === ScanCode.ArrowLeft || chord.scanCode === ScanCode.ArrowRight)) {536// console.warn('Ctrl/Cmd+Arrow keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);537return true;538}539if (chord instanceof KeyCodeChord && (chord.keyCode === KeyCode.LeftArrow || chord.keyCode === KeyCode.RightArrow)) {540// console.warn('Ctrl/Cmd+Arrow keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);541return true;542}543}544545if ((partModifiersMask & modifiersMask) === KeyMod.CtrlCmd) {546if (chord instanceof ScanCodeChord && (chord.scanCode >= ScanCode.Digit1 && chord.scanCode <= ScanCode.Digit0)) {547// console.warn('Ctrl/Cmd+Num keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);548return true;549}550if (chord instanceof KeyCodeChord && (chord.keyCode >= KeyCode.Digit0 && chord.keyCode <= KeyCode.Digit9)) {551// console.warn('Ctrl/Cmd+Num keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);552return true;553}554}555}556557return false;558}559560public resolveKeybinding(kb: Keybinding): ResolvedKeybinding[] {561return this._keyboardMapper.resolveKeybinding(kb);562}563564public resolveKeyboardEvent(keyboardEvent: IKeyboardEvent): ResolvedKeybinding {565this.keyboardLayoutService.validateCurrentKeyboardMapping(keyboardEvent);566return this._keyboardMapper.resolveKeyboardEvent(keyboardEvent);567}568569public resolveUserBinding(userBinding: string): ResolvedKeybinding[] {570const keybinding = KeybindingParser.parseKeybinding(userBinding);571return (keybinding ? this._keyboardMapper.resolveKeybinding(keybinding) : []);572}573574private _handleKeybindingsExtensionPointUser(extensionId: ExtensionIdentifier, isBuiltin: boolean, keybindings: ContributedKeyBinding | ContributedKeyBinding[], collector: ExtensionMessageCollector, result: IExtensionKeybindingRule[]): void {575if (Array.isArray(keybindings)) {576for (let i = 0, len = keybindings.length; i < len; i++) {577this._handleKeybinding(extensionId, isBuiltin, i + 1, keybindings[i], collector, result);578}579} else {580this._handleKeybinding(extensionId, isBuiltin, 1, keybindings, collector, result);581}582}583584private _handleKeybinding(extensionId: ExtensionIdentifier, isBuiltin: boolean, idx: number, keybindings: ContributedKeyBinding, collector: ExtensionMessageCollector, result: IExtensionKeybindingRule[]): void {585586const rejects: string[] = [];587588if (isValidContributedKeyBinding(keybindings, rejects)) {589const rule = this._asCommandRule(extensionId, isBuiltin, idx++, keybindings);590if (rule) {591result.push(rule);592}593}594595if (rejects.length > 0) {596collector.error(nls.localize(597'invalid.keybindings',598"Invalid `contributes.{0}`: {1}",599keybindingsExtPoint.name,600rejects.join('\n')601));602}603}604605private static bindToCurrentPlatform(key: string | undefined, mac: string | undefined, linux: string | undefined, win: string | undefined): string | undefined {606if (OS === OperatingSystem.Windows && win) {607if (win) {608return win;609}610} else if (OS === OperatingSystem.Macintosh) {611if (mac) {612return mac;613}614} else {615if (linux) {616return linux;617}618}619return key;620}621622private _asCommandRule(extensionId: ExtensionIdentifier, isBuiltin: boolean, idx: number, binding: ContributedKeyBinding): IExtensionKeybindingRule | undefined {623624const { command, args, when, key, mac, linux, win } = binding;625const keybinding = WorkbenchKeybindingService.bindToCurrentPlatform(key, mac, linux, win);626if (!keybinding) {627return undefined;628}629630let weight: number;631if (isBuiltin) {632weight = KeybindingWeight.BuiltinExtension + idx;633} else {634weight = KeybindingWeight.ExternalExtension + idx;635}636637const commandAction = MenuRegistry.getCommand(command);638const precondition = commandAction && commandAction.precondition;639let fullWhen: ContextKeyExpression | undefined;640if (when && precondition) {641fullWhen = ContextKeyExpr.and(precondition, ContextKeyExpr.deserialize(when));642} else if (when) {643fullWhen = ContextKeyExpr.deserialize(when);644} else if (precondition) {645fullWhen = precondition;646}647648const desc: IExtensionKeybindingRule = {649id: command,650args,651when: fullWhen,652weight: weight,653keybinding: KeybindingParser.parseKeybinding(keybinding),654extensionId: extensionId.value,655isBuiltinExtension: isBuiltin656};657return desc;658}659660public override getDefaultKeybindingsContent(): string {661const resolver = this._getResolver();662const defaultKeybindings = resolver.getDefaultKeybindings();663const boundCommands = resolver.getDefaultBoundCommands();664return (665WorkbenchKeybindingService._getDefaultKeybindings(defaultKeybindings)666+ '\n\n'667+ WorkbenchKeybindingService._getAllCommandsAsComment(boundCommands)668);669}670671private static _getDefaultKeybindings(defaultKeybindings: readonly ResolvedKeybindingItem[]): string {672const out = new OutputBuilder();673out.writeLine('[');674675const lastIndex = defaultKeybindings.length - 1;676defaultKeybindings.forEach((k, index) => {677KeybindingIO.writeKeybindingItem(out, k);678if (index !== lastIndex) {679out.writeLine(',');680} else {681out.writeLine();682}683});684out.writeLine(']');685return out.toString();686}687688private static _getAllCommandsAsComment(boundCommands: Map<string, boolean>): string {689const unboundCommands = getAllUnboundCommands(boundCommands);690const pretty = unboundCommands.sort().join('\n// - ');691return '// ' + nls.localize('unboundCommands', "Here are other available commands: ") + '\n// - ' + pretty;692}693694override mightProducePrintableCharacter(event: IKeyboardEvent): boolean {695if (event.ctrlKey || event.metaKey || event.altKey) {696// ignore ctrl/cmd/alt-combination but not shift-combinatios697return false;698}699const code = ScanCodeUtils.toEnum(event.code);700701if (NUMPAD_PRINTABLE_SCANCODES.indexOf(code) !== -1) {702// This is a numpad key that might produce a printable character based on NumLock.703// Let's check if NumLock is on or off based on the event's keyCode.704// e.g.705// - when NumLock is off, ScanCode.Numpad4 produces KeyCode.LeftArrow706// - when NumLock is on, ScanCode.Numpad4 produces KeyCode.NUMPAD_4707// However, ScanCode.NumpadAdd always produces KeyCode.NUMPAD_ADD708if (event.keyCode === IMMUTABLE_CODE_TO_KEY_CODE[code]) {709// NumLock is on or this is /, *, -, + on the numpad710return true;711}712if (isMacintosh && event.keyCode === otherMacNumpadMapping.get(code)) {713// on macOS, the numpad keys can also map to keys 1 - 0.714return true;715}716return false;717}718719const keycode = IMMUTABLE_CODE_TO_KEY_CODE[code];720if (keycode !== -1) {721// https://github.com/microsoft/vscode/issues/74934722return false;723}724// consult the KeyboardMapperFactory to check the given event for725// a printable value.726const mapping = this.keyboardLayoutService.getRawKeyboardMapping();727if (!mapping) {728return false;729}730const keyInfo = mapping[event.code];731if (!keyInfo) {732return false;733}734if (!keyInfo.value || /\s/.test(keyInfo.value)) {735return false;736}737return true;738}739}740741class UserKeybindings extends Disposable {742743private _rawKeybindings: Object[] = [];744private _keybindings: IUserKeybindingItem[] = [];745get keybindings(): IUserKeybindingItem[] { return this._keybindings; }746747private readonly reloadConfigurationScheduler: RunOnceScheduler;748749private readonly watchDisposables = this._register(new DisposableStore());750751private readonly _onDidChange: Emitter<void> = this._register(new Emitter<void>());752readonly onDidChange: Event<void> = this._onDidChange.event;753754constructor(755private readonly userDataProfileService: IUserDataProfileService,756private readonly uriIdentityService: IUriIdentityService,757private readonly fileService: IFileService,758logService: ILogService,759) {760super();761762this.watch();763764this.reloadConfigurationScheduler = this._register(new RunOnceScheduler(() => this.reload().then(changed => {765if (changed) {766this._onDidChange.fire();767}768}), 50));769770this._register(Event.filter(this.fileService.onDidFilesChange, e => e.contains(this.userDataProfileService.currentProfile.keybindingsResource))(() => {771logService.debug('Keybindings file changed');772this.reloadConfigurationScheduler.schedule();773}));774775this._register(this.fileService.onDidRunOperation((e) => {776if (e.operation === FileOperation.WRITE && e.resource.toString() === this.userDataProfileService.currentProfile.keybindingsResource.toString()) {777logService.debug('Keybindings file written');778this.reloadConfigurationScheduler.schedule();779}780}));781782this._register(userDataProfileService.onDidChangeCurrentProfile(e => {783if (!this.uriIdentityService.extUri.isEqual(e.previous.keybindingsResource, e.profile.keybindingsResource)) {784e.join(this.whenCurrentProfileChanged());785}786}));787}788789private async whenCurrentProfileChanged(): Promise<void> {790this.watch();791this.reloadConfigurationScheduler.schedule();792}793794private watch(): void {795this.watchDisposables.clear();796this.watchDisposables.add(this.fileService.watch(dirname(this.userDataProfileService.currentProfile.keybindingsResource)));797// Also listen to the resource incase the resource is a symlink - https://github.com/microsoft/vscode/issues/118134798this.watchDisposables.add(this.fileService.watch(this.userDataProfileService.currentProfile.keybindingsResource));799}800801async initialize(): Promise<void> {802await this.reload();803}804805private async reload(): Promise<boolean> {806const newKeybindings = await this.readUserKeybindings();807if (objects.equals(this._rawKeybindings, newKeybindings)) {808// no change809return false;810}811812this._rawKeybindings = newKeybindings;813this._keybindings = this._rawKeybindings.map((k) => KeybindingIO.readUserKeybindingItem(k));814return true;815}816817private async readUserKeybindings(): Promise<Object[]> {818try {819const content = await this.fileService.readFile(this.userDataProfileService.currentProfile.keybindingsResource);820const value = parse(content.value.toString());821return Array.isArray(value)822? value.filter(v => v && typeof v === 'object' /* just typeof === object doesn't catch `null` */)823: [];824} catch (e) {825return [];826}827}828}829830/**831* 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).832*833* Lifecycle owned by `WorkbenchKeybindingService`. Must be instantiated only once.834*/835class KeybindingsJsonSchema {836837private static readonly schemaId = 'vscode://schemas/keybindings';838839private readonly commandsSchemas: IJSONSchema[] = [];840private readonly commandsEnum: string[] = [];841private readonly removalCommandsEnum: string[] = [];842private readonly commandsEnumDescriptions: string[] = [];843private readonly schema: IJSONSchema = {844id: KeybindingsJsonSchema.schemaId,845type: 'array',846title: nls.localize('keybindings.json.title', "Keybindings configuration"),847allowTrailingCommas: true,848allowComments: true,849definitions: {850'editorGroupsSchema': {851'type': 'array',852'items': {853'type': 'object',854'properties': {855'groups': {856'$ref': '#/definitions/editorGroupsSchema',857'default': [{}, {}]858},859'size': {860'type': 'number',861'default': 0.5862}863}864}865},866'commandNames': {867'type': 'string',868'enum': this.commandsEnum,869'enumDescriptions': this.commandsEnumDescriptions,870'description': nls.localize('keybindings.json.command', "Name of the command to execute"),871},872'commandType': {873'anyOf': [ // repetition of this clause here and below is intentional: one is for nice diagnostics & one is for code completion874{875$ref: '#/definitions/commandNames'876},877{878'type': 'string',879'enum': this.removalCommandsEnum,880'enumDescriptions': this.commandsEnumDescriptions,881'description': nls.localize('keybindings.json.removalCommand', "Name of the command to remove keyboard shortcut for"),882},883{884'type': 'string'885},886]887},888'commandsSchemas': {889'allOf': this.commandsSchemas890}891},892items: {893'required': ['key'],894'type': 'object',895'defaultSnippets': [{ 'body': { 'key': '$1', 'command': '$2', 'when': '$3' } }],896'properties': {897'key': {898'type': 'string',899'description': nls.localize('keybindings.json.key', "Key or key sequence (separated by space)"),900},901'command': {902'anyOf': [903{904'if': {905'type': 'array'906},907'then': {908'not': {909'type': 'array'910},911'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')912},913'else': {914'$ref': '#/definitions/commandType'915}916},917{918'$ref': '#/definitions/commandType'919}920]921},922'when': {923'type': 'string',924'description': nls.localize('keybindings.json.when', "Condition when the key is active.")925},926'args': {927'description': nls.localize('keybindings.json.args', "Arguments to pass to the command to execute.")928}929},930'$ref': '#/definitions/commandsSchemas'931}932};933934private readonly schemaRegistry = Registry.as<IJSONContributionRegistry>(Extensions.JSONContribution);935936constructor() {937this.schemaRegistry.registerSchema(KeybindingsJsonSchema.schemaId, this.schema);938}939940// TODO@ulugbekna: can updates happen incrementally rather than rebuilding; concerns:941// - is just appending additional schemas enough for the registry to pick them up?942// - can `CommandsRegistry.getCommands` and `MenuRegistry.getCommands` return different values at different times? ie would just pushing new schemas from `additionalContributions` not be enough?943updateSchema(additionalContributions: readonly IJSONSchema[]) {944this.commandsSchemas.length = 0;945this.commandsEnum.length = 0;946this.removalCommandsEnum.length = 0;947this.commandsEnumDescriptions.length = 0;948949const knownCommands = new Set<string>();950const addKnownCommand = (commandId: string, description?: string | ILocalizedString | undefined) => {951if (!/^_/.test(commandId)) {952if (!knownCommands.has(commandId)) {953knownCommands.add(commandId);954955this.commandsEnum.push(commandId);956this.commandsEnumDescriptions.push(957description === undefined958? '' // `enumDescriptions` is an array of strings, so we can't use undefined959: (isLocalizedString(description) ? description.value : description)960);961962// Also add the negative form for keybinding removal963this.removalCommandsEnum.push(`-${commandId}`);964}965}966};967968const allCommands = CommandsRegistry.getCommands();969for (const [commandId, command] of allCommands) {970const commandMetadata = command.metadata;971972addKnownCommand(commandId, commandMetadata?.description ?? MenuRegistry.getCommand(commandId)?.title);973974if (!commandMetadata || !commandMetadata.args || commandMetadata.args.length !== 1 || !commandMetadata.args[0].schema) {975continue;976}977978const argsSchema = commandMetadata.args[0].schema;979const argsRequired = (980(typeof commandMetadata.args[0].isOptional !== 'undefined')981? (!commandMetadata.args[0].isOptional)982: (Array.isArray(argsSchema.required) && argsSchema.required.length > 0)983);984const addition = {985'if': {986'required': ['command'],987'properties': {988'command': { 'const': commandId }989}990},991'then': {992'required': (<string[]>[]).concat(argsRequired ? ['args'] : []),993'properties': {994'args': argsSchema995}996}997};998999this.commandsSchemas.push(addition);1000}10011002const menuCommands = MenuRegistry.getCommands();1003for (const commandId of menuCommands.keys()) {1004addKnownCommand(commandId);1005}10061007this.commandsSchemas.push(...additionalContributions);1008this.schemaRegistry.notifySchemaChanged(KeybindingsJsonSchema.schemaId);1009}1010}10111012registerSingleton(IKeybindingService, WorkbenchKeybindingService, InstantiationType.Eager);101310141015