Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/services/keybinding/browser/keybindingService.ts
5221 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as nls from '../../../../nls.js';
7
8
// base
9
import * as browser from '../../../../base/browser/browser.js';
10
import { BrowserFeatures, KeyboardSupport } from '../../../../base/browser/canIUse.js';
11
import * as dom from '../../../../base/browser/dom.js';
12
import { printKeyboardEvent, printStandardKeyboardEvent, StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
13
import { mainWindow } from '../../../../base/browser/window.js';
14
import { DeferredPromise, RunOnceScheduler } from '../../../../base/common/async.js';
15
import { Emitter, Event } from '../../../../base/common/event.js';
16
import { parse } from '../../../../base/common/json.js';
17
import { IJSONSchema, TypeFromJsonSchema } from '../../../../base/common/jsonSchema.js';
18
import { UserSettingsLabelProvider } from '../../../../base/common/keybindingLabels.js';
19
import { KeybindingParser } from '../../../../base/common/keybindingParser.js';
20
import { Keybinding, KeyCodeChord, ResolvedKeybinding, ScanCodeChord } from '../../../../base/common/keybindings.js';
21
import { IMMUTABLE_CODE_TO_KEY_CODE, KeyCode, KeyCodeUtils, KeyMod, ScanCode, ScanCodeUtils } from '../../../../base/common/keyCodes.js';
22
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
23
import * as objects from '../../../../base/common/objects.js';
24
import { isMacintosh, OperatingSystem, OS } from '../../../../base/common/platform.js';
25
import { dirname } from '../../../../base/common/resources.js';
26
27
// platform
28
import { ILocalizedString, isLocalizedString } from '../../../../platform/action/common/action.js';
29
import { MenuRegistry } from '../../../../platform/actions/common/actions.js';
30
import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js';
31
import { ContextKeyExpr, ContextKeyExpression, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
32
import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js';
33
import { FileOperation, IFileService } from '../../../../platform/files/common/files.js';
34
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
35
import { Extensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
36
import { AbstractKeybindingService } from '../../../../platform/keybinding/common/abstractKeybindingService.js';
37
import { IKeybindingService, IKeyboardEvent, KeybindingsSchemaContribution } from '../../../../platform/keybinding/common/keybinding.js';
38
import { KeybindingResolver } from '../../../../platform/keybinding/common/keybindingResolver.js';
39
import { IExtensionKeybindingRule, IKeybindingItem, KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';
40
import { ResolvedKeybindingItem } from '../../../../platform/keybinding/common/resolvedKeybindingItem.js';
41
import { IKeyboardLayoutService } from '../../../../platform/keyboardLayout/common/keyboardLayout.js';
42
import { IKeyboardMapper } from '../../../../platform/keyboardLayout/common/keyboardMapper.js';
43
import { ILogService } from '../../../../platform/log/common/log.js';
44
import { INotificationService } from '../../../../platform/notification/common/notification.js';
45
import { Registry } from '../../../../platform/registry/common/platform.js';
46
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
47
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
48
49
// workbench
50
import { remove } from '../../../../base/common/arrays.js';
51
import { commandsExtensionPoint } from '../../actions/common/menusExtensionPoint.js';
52
import { IExtensionService } from '../../extensions/common/extensions.js';
53
import { ExtensionMessageCollector, ExtensionsRegistry } from '../../extensions/common/extensionsRegistry.js';
54
import { IHostService } from '../../host/browser/host.js';
55
import { IUserDataProfileService } from '../../userDataProfile/common/userDataProfile.js';
56
import { IUserKeybindingItem, KeybindingIO, OutputBuilder } from '../common/keybindingIO.js';
57
import { IKeyboard, INavigatorWithKeyboard } from './navigatorKeyboard.js';
58
import { getAllUnboundCommands } from './unboundCommands.js';
59
import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';
60
61
function isValidContributedKeyBinding(keyBinding: ContributedKeyBinding, rejects: string[]): boolean {
62
if (!keyBinding) {
63
rejects.push(nls.localize('nonempty', "expected non-empty value."));
64
return false;
65
}
66
if (typeof keyBinding.command !== 'string') {
67
rejects.push(nls.localize('requirestring', "property `{0}` is mandatory and must be of type `string`", 'command'));
68
return false;
69
}
70
if (keyBinding.key && typeof keyBinding.key !== 'string') {
71
rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'key'));
72
return false;
73
}
74
if (keyBinding.when && typeof keyBinding.when !== 'string') {
75
rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'when'));
76
return false;
77
}
78
if (keyBinding.mac && typeof keyBinding.mac !== 'string') {
79
rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'mac'));
80
return false;
81
}
82
if (keyBinding.linux && typeof keyBinding.linux !== 'string') {
83
rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'linux'));
84
return false;
85
}
86
if (keyBinding.win && typeof keyBinding.win !== 'string') {
87
rejects.push(nls.localize('optstring', "property `{0}` can be omitted or must be of type `string`", 'win'));
88
return false;
89
}
90
return true;
91
}
92
93
const keybindingType = {
94
type: 'object',
95
default: { command: '', key: '' },
96
required: ['command', 'key'],
97
properties: {
98
command: {
99
description: nls.localize('vscode.extension.contributes.keybindings.command', 'Identifier of the command to run when keybinding is triggered.'),
100
type: 'string'
101
},
102
args: {
103
description: nls.localize('vscode.extension.contributes.keybindings.args', "Arguments to pass to the command to execute.")
104
},
105
key: {
106
description: 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).'),
107
type: 'string'
108
},
109
mac: {
110
description: nls.localize('vscode.extension.contributes.keybindings.mac', 'Mac specific key or key sequence.'),
111
type: 'string'
112
},
113
linux: {
114
description: nls.localize('vscode.extension.contributes.keybindings.linux', 'Linux specific key or key sequence.'),
115
type: 'string'
116
},
117
win: {
118
description: nls.localize('vscode.extension.contributes.keybindings.win', 'Windows specific key or key sequence.'),
119
type: 'string'
120
},
121
when: {
122
description: nls.localize('vscode.extension.contributes.keybindings.when', 'Condition when the key is active.'),
123
type: 'string'
124
},
125
}
126
} as const satisfies IJSONSchema;
127
128
type ContributedKeyBinding = TypeFromJsonSchema<typeof keybindingType>;
129
130
const keybindingsExtPoint = ExtensionsRegistry.registerExtensionPoint<ContributedKeyBinding | ContributedKeyBinding[]>({
131
extensionPoint: 'keybindings',
132
deps: [commandsExtensionPoint],
133
jsonSchema: {
134
description: nls.localize('vscode.extension.contributes.keybindings', "Contributes keybindings."),
135
oneOf: [
136
keybindingType,
137
{
138
type: 'array',
139
items: keybindingType
140
}
141
]
142
}
143
});
144
145
const NUMPAD_PRINTABLE_SCANCODES = [
146
ScanCode.NumpadDivide,
147
ScanCode.NumpadMultiply,
148
ScanCode.NumpadSubtract,
149
ScanCode.NumpadAdd,
150
ScanCode.Numpad1,
151
ScanCode.Numpad2,
152
ScanCode.Numpad3,
153
ScanCode.Numpad4,
154
ScanCode.Numpad5,
155
ScanCode.Numpad6,
156
ScanCode.Numpad7,
157
ScanCode.Numpad8,
158
ScanCode.Numpad9,
159
ScanCode.Numpad0,
160
ScanCode.NumpadDecimal
161
];
162
163
const otherMacNumpadMapping = new Map<ScanCode, KeyCode>();
164
otherMacNumpadMapping.set(ScanCode.Numpad1, KeyCode.Digit1);
165
otherMacNumpadMapping.set(ScanCode.Numpad2, KeyCode.Digit2);
166
otherMacNumpadMapping.set(ScanCode.Numpad3, KeyCode.Digit3);
167
otherMacNumpadMapping.set(ScanCode.Numpad4, KeyCode.Digit4);
168
otherMacNumpadMapping.set(ScanCode.Numpad5, KeyCode.Digit5);
169
otherMacNumpadMapping.set(ScanCode.Numpad6, KeyCode.Digit6);
170
otherMacNumpadMapping.set(ScanCode.Numpad7, KeyCode.Digit7);
171
otherMacNumpadMapping.set(ScanCode.Numpad8, KeyCode.Digit8);
172
otherMacNumpadMapping.set(ScanCode.Numpad9, KeyCode.Digit9);
173
otherMacNumpadMapping.set(ScanCode.Numpad0, KeyCode.Digit0);
174
175
export class WorkbenchKeybindingService extends AbstractKeybindingService {
176
177
private _keyboardMapper: IKeyboardMapper;
178
private _cachedResolver: KeybindingResolver | null;
179
private userKeybindings: UserKeybindings;
180
private isComposingGlobalContextKey: IContextKey<boolean>;
181
private _keybindingHoldMode: DeferredPromise<void> | null;
182
private readonly _contributions: Array<{
183
readonly listener?: IDisposable;
184
readonly contribution: KeybindingsSchemaContribution;
185
}> = [];
186
private readonly kbsJsonSchema: KeybindingsJsonSchema;
187
188
constructor(
189
@IContextKeyService contextKeyService: IContextKeyService,
190
@ICommandService commandService: ICommandService,
191
@ITelemetryService telemetryService: ITelemetryService,
192
@INotificationService notificationService: INotificationService,
193
@IUserDataProfileService userDataProfileService: IUserDataProfileService,
194
@IHostService private readonly hostService: IHostService,
195
@IExtensionService extensionService: IExtensionService,
196
@IFileService fileService: IFileService,
197
@IUriIdentityService uriIdentityService: IUriIdentityService,
198
@ILogService logService: ILogService,
199
@IKeyboardLayoutService private readonly keyboardLayoutService: IKeyboardLayoutService
200
) {
201
super(contextKeyService, commandService, telemetryService, notificationService, logService);
202
203
this.isComposingGlobalContextKey = contextKeyService.createKey(EditorContextKeys.isComposing.key, false);
204
205
this.kbsJsonSchema = new KeybindingsJsonSchema();
206
this.updateKeybindingsJsonSchema();
207
208
this._keyboardMapper = this.keyboardLayoutService.getKeyboardMapper();
209
this._register(this.keyboardLayoutService.onDidChangeKeyboardLayout(() => {
210
this._keyboardMapper = this.keyboardLayoutService.getKeyboardMapper();
211
this.updateResolver();
212
}));
213
214
this._keybindingHoldMode = null;
215
this._cachedResolver = null;
216
217
this.userKeybindings = this._register(new UserKeybindings(userDataProfileService, uriIdentityService, fileService, logService));
218
this.userKeybindings.initialize().then(() => {
219
if (this.userKeybindings.keybindings.length) {
220
this.updateResolver();
221
}
222
});
223
this._register(this.userKeybindings.onDidChange(() => {
224
logService.debug('User keybindings changed');
225
this.updateResolver();
226
}));
227
228
keybindingsExtPoint.setHandler((extensions) => {
229
230
const keybindings: IExtensionKeybindingRule[] = [];
231
for (const extension of extensions) {
232
this._handleKeybindingsExtensionPointUser(extension.description.identifier, extension.description.isBuiltin, extension.value, extension.collector, keybindings);
233
}
234
235
KeybindingsRegistry.setExtensionKeybindings(keybindings);
236
this.updateResolver();
237
});
238
239
this.updateKeybindingsJsonSchema();
240
this._register(extensionService.onDidRegisterExtensions(() => this.updateKeybindingsJsonSchema()));
241
242
this._register(Event.runAndSubscribe(dom.onDidRegisterWindow, ({ window, disposables }) => disposables.add(this._registerKeyListeners(window)), { window: mainWindow, disposables: this._store }));
243
244
this._register(browser.onDidChangeFullscreen(windowId => {
245
if (windowId !== mainWindow.vscodeWindowId) {
246
return;
247
}
248
249
const keyboard: IKeyboard | null = (<INavigatorWithKeyboard>navigator).keyboard;
250
251
if (BrowserFeatures.keyboard === KeyboardSupport.None) {
252
return;
253
}
254
255
if (browser.isFullscreen(mainWindow)) {
256
keyboard?.lock(['Escape']);
257
} else {
258
keyboard?.unlock();
259
}
260
261
// update resolver which will bring back all unbound keyboard shortcuts
262
this._cachedResolver = null;
263
this._onDidUpdateKeybindings.fire();
264
}));
265
}
266
267
public override dispose(): void {
268
this._contributions.forEach(c => c.listener?.dispose());
269
this._contributions.length = 0;
270
271
super.dispose();
272
}
273
274
private _registerKeyListeners(window: Window): IDisposable {
275
const disposables = new DisposableStore();
276
277
// for standard keybindings
278
disposables.add(dom.addDisposableListener(window, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {
279
if (this._keybindingHoldMode) {
280
return;
281
}
282
this.isComposingGlobalContextKey.set(e.isComposing);
283
const keyEvent = new StandardKeyboardEvent(e);
284
this._log(`/ Received keydown event - ${printKeyboardEvent(e)}`);
285
this._log(`| Converted keydown event - ${printStandardKeyboardEvent(keyEvent)}`);
286
const shouldPreventDefault = this._dispatch(keyEvent, keyEvent.target);
287
if (shouldPreventDefault) {
288
keyEvent.preventDefault();
289
}
290
this.isComposingGlobalContextKey.set(false);
291
}));
292
293
// for single modifier chord keybindings (e.g. shift shift)
294
disposables.add(dom.addDisposableListener(window, dom.EventType.KEY_UP, (e: KeyboardEvent) => {
295
this._resetKeybindingHoldMode();
296
this.isComposingGlobalContextKey.set(e.isComposing);
297
const keyEvent = new StandardKeyboardEvent(e);
298
const shouldPreventDefault = this._singleModifierDispatch(keyEvent, keyEvent.target);
299
if (shouldPreventDefault) {
300
keyEvent.preventDefault();
301
}
302
this.isComposingGlobalContextKey.set(false);
303
}));
304
305
return disposables;
306
}
307
308
public registerSchemaContribution(contribution: KeybindingsSchemaContribution): IDisposable {
309
const listener = contribution.onDidChange?.(() => this.updateKeybindingsJsonSchema());
310
const entry = { listener, contribution };
311
this._contributions.push(entry);
312
313
this.updateKeybindingsJsonSchema();
314
315
return toDisposable(() => {
316
listener?.dispose();
317
remove(this._contributions, entry);
318
this.updateKeybindingsJsonSchema();
319
});
320
}
321
322
private updateKeybindingsJsonSchema() {
323
this.kbsJsonSchema.updateSchema(this._contributions.flatMap(x => x.contribution.getSchemaAdditions()));
324
}
325
326
private _printKeybinding(keybinding: Keybinding): string {
327
return UserSettingsLabelProvider.toLabel(OS, keybinding.chords, (chord) => {
328
if (chord instanceof KeyCodeChord) {
329
return KeyCodeUtils.toString(chord.keyCode);
330
}
331
return ScanCodeUtils.toString(chord.scanCode);
332
}) || '[null]';
333
}
334
335
private _printResolvedKeybinding(resolvedKeybinding: ResolvedKeybinding): string {
336
return resolvedKeybinding.getDispatchChords().map(x => x || '[null]').join(' ');
337
}
338
339
private _printResolvedKeybindings(output: string[], input: string, resolvedKeybindings: ResolvedKeybinding[]): void {
340
const padLength = 35;
341
const firstRow = `${input.padStart(padLength, ' ')} => `;
342
if (resolvedKeybindings.length === 0) {
343
// no binding found
344
output.push(`${firstRow}${'[NO BINDING]'.padStart(padLength, ' ')}`);
345
return;
346
}
347
348
const firstRowIndentation = firstRow.length;
349
const isFirst = true;
350
for (const resolvedKeybinding of resolvedKeybindings) {
351
if (isFirst) {
352
output.push(`${firstRow}${this._printResolvedKeybinding(resolvedKeybinding).padStart(padLength, ' ')}`);
353
} else {
354
output.push(`${' '.repeat(firstRowIndentation)}${this._printResolvedKeybinding(resolvedKeybinding).padStart(padLength, ' ')}`);
355
}
356
}
357
}
358
359
private _dumpResolveKeybindingDebugInfo(): string {
360
361
const seenBindings = new Set<string>();
362
const result: string[] = [];
363
364
result.push(`Default Resolved Keybindings (unique only):`);
365
for (const item of KeybindingsRegistry.getDefaultKeybindings()) {
366
if (!item.keybinding) {
367
continue;
368
}
369
const input = this._printKeybinding(item.keybinding);
370
if (seenBindings.has(input)) {
371
continue;
372
}
373
seenBindings.add(input);
374
const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(item.keybinding);
375
this._printResolvedKeybindings(result, input, resolvedKeybindings);
376
}
377
378
result.push(`User Resolved Keybindings (unique only):`);
379
for (const item of this.userKeybindings.keybindings) {
380
if (!item.keybinding) {
381
continue;
382
}
383
const input = item._sourceKey ?? 'Impossible: missing source key, but has keybinding';
384
if (seenBindings.has(input)) {
385
continue;
386
}
387
seenBindings.add(input);
388
const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(item.keybinding);
389
this._printResolvedKeybindings(result, input, resolvedKeybindings);
390
}
391
392
return result.join('\n');
393
}
394
395
public _dumpDebugInfo(): string {
396
const layoutInfo = JSON.stringify(this.keyboardLayoutService.getCurrentKeyboardLayout(), null, '\t');
397
const mapperInfo = this._keyboardMapper.dumpDebugInfo();
398
const resolvedKeybindings = this._dumpResolveKeybindingDebugInfo();
399
const rawMapping = JSON.stringify(this.keyboardLayoutService.getRawKeyboardMapping(), null, '\t');
400
return `Layout info:\n${layoutInfo}\n\n${resolvedKeybindings}\n\n${mapperInfo}\n\nRaw mapping:\n${rawMapping}`;
401
}
402
403
public _dumpDebugInfoJSON(): string {
404
const info = {
405
layout: this.keyboardLayoutService.getCurrentKeyboardLayout(),
406
rawMapping: this.keyboardLayoutService.getRawKeyboardMapping()
407
};
408
return JSON.stringify(info, null, '\t');
409
}
410
411
public override enableKeybindingHoldMode(commandId: string): Promise<void> | undefined {
412
if (this._currentlyDispatchingCommandId !== commandId) {
413
return undefined;
414
}
415
this._keybindingHoldMode = new DeferredPromise<void>();
416
const focusTracker = dom.trackFocus(dom.getWindow(undefined));
417
const listener = focusTracker.onDidBlur(() => this._resetKeybindingHoldMode());
418
this._keybindingHoldMode.p.finally(() => {
419
listener.dispose();
420
focusTracker.dispose();
421
});
422
this._log(`+ Enabled hold-mode for ${commandId}.`);
423
return this._keybindingHoldMode.p;
424
}
425
426
private _resetKeybindingHoldMode(): void {
427
if (this._keybindingHoldMode) {
428
this._keybindingHoldMode?.complete();
429
this._keybindingHoldMode = null;
430
}
431
}
432
433
public override customKeybindingsCount(): number {
434
return this.userKeybindings.keybindings.length;
435
}
436
437
private updateResolver(): void {
438
this._cachedResolver = null;
439
this._onDidUpdateKeybindings.fire();
440
}
441
442
protected _getResolver(): KeybindingResolver {
443
if (!this._cachedResolver) {
444
const defaults = this._resolveKeybindingItems(KeybindingsRegistry.getDefaultKeybindings(), true);
445
const overrides = this._resolveUserKeybindingItems(this.userKeybindings.keybindings, false);
446
this._cachedResolver = new KeybindingResolver(defaults, overrides, (str) => this._log(str));
447
}
448
return this._cachedResolver;
449
}
450
451
protected _documentHasFocus(): boolean {
452
// it is possible that the document has lost focus, but the
453
// window is still focused, e.g. when a <webview> element
454
// has focus
455
return this.hostService.hasFocus;
456
}
457
458
private _resolveKeybindingItems(items: IKeybindingItem[], isDefault: boolean): ResolvedKeybindingItem[] {
459
const result: ResolvedKeybindingItem[] = [];
460
let resultLen = 0;
461
for (const item of items) {
462
const when = item.when || undefined;
463
const keybinding = item.keybinding;
464
if (!keybinding) {
465
// This might be a removal keybinding item in user settings => accept it
466
result[resultLen++] = new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, isDefault, item.extensionId, item.isBuiltinExtension);
467
} else {
468
if (this._assertBrowserConflicts(keybinding)) {
469
continue;
470
}
471
472
const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(keybinding);
473
for (let i = resolvedKeybindings.length - 1; i >= 0; i--) {
474
const resolvedKeybinding = resolvedKeybindings[i];
475
result[resultLen++] = new ResolvedKeybindingItem(resolvedKeybinding, item.command, item.commandArgs, when, isDefault, item.extensionId, item.isBuiltinExtension);
476
}
477
}
478
}
479
480
return result;
481
}
482
483
private _resolveUserKeybindingItems(items: IUserKeybindingItem[], isDefault: boolean): ResolvedKeybindingItem[] {
484
const result: ResolvedKeybindingItem[] = [];
485
let resultLen = 0;
486
for (const item of items) {
487
const when = item.when || undefined;
488
if (!item.keybinding) {
489
// This might be a removal keybinding item in user settings => accept it
490
result[resultLen++] = new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, isDefault, null, false);
491
} else {
492
const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(item.keybinding);
493
for (const resolvedKeybinding of resolvedKeybindings) {
494
result[resultLen++] = new ResolvedKeybindingItem(resolvedKeybinding, item.command, item.commandArgs, when, isDefault, null, false);
495
}
496
}
497
}
498
499
return result;
500
}
501
502
private _assertBrowserConflicts(keybinding: Keybinding): boolean {
503
if (BrowserFeatures.keyboard === KeyboardSupport.Always) {
504
return false;
505
}
506
507
if (BrowserFeatures.keyboard === KeyboardSupport.FullScreen && browser.isFullscreen(mainWindow)) {
508
return false;
509
}
510
511
for (const chord of keybinding.chords) {
512
if (!chord.metaKey && !chord.altKey && !chord.ctrlKey && !chord.shiftKey) {
513
continue;
514
}
515
516
const modifiersMask = KeyMod.CtrlCmd | KeyMod.Alt | KeyMod.Shift;
517
518
let partModifiersMask = 0;
519
if (chord.metaKey) {
520
partModifiersMask |= KeyMod.CtrlCmd;
521
}
522
523
if (chord.shiftKey) {
524
partModifiersMask |= KeyMod.Shift;
525
}
526
527
if (chord.altKey) {
528
partModifiersMask |= KeyMod.Alt;
529
}
530
531
if (chord.ctrlKey && OS === OperatingSystem.Macintosh) {
532
partModifiersMask |= KeyMod.WinCtrl;
533
}
534
535
if ((partModifiersMask & modifiersMask) === (KeyMod.CtrlCmd | KeyMod.Alt)) {
536
if (chord instanceof ScanCodeChord && (chord.scanCode === ScanCode.ArrowLeft || chord.scanCode === ScanCode.ArrowRight)) {
537
// console.warn('Ctrl/Cmd+Arrow keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);
538
return true;
539
}
540
if (chord instanceof KeyCodeChord && (chord.keyCode === KeyCode.LeftArrow || chord.keyCode === KeyCode.RightArrow)) {
541
// console.warn('Ctrl/Cmd+Arrow keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);
542
return true;
543
}
544
}
545
546
if ((partModifiersMask & modifiersMask) === KeyMod.CtrlCmd) {
547
if (chord instanceof ScanCodeChord && (chord.scanCode >= ScanCode.Digit1 && chord.scanCode <= ScanCode.Digit0)) {
548
// console.warn('Ctrl/Cmd+Num keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);
549
return true;
550
}
551
if (chord instanceof KeyCodeChord && (chord.keyCode >= KeyCode.Digit0 && chord.keyCode <= KeyCode.Digit9)) {
552
// console.warn('Ctrl/Cmd+Num keybindings should not be used by default in web. Offender: ', kb.getHashCode(), ' for ', commandId);
553
return true;
554
}
555
}
556
}
557
558
return false;
559
}
560
561
public resolveKeybinding(kb: Keybinding): ResolvedKeybinding[] {
562
return this._keyboardMapper.resolveKeybinding(kb);
563
}
564
565
public resolveKeyboardEvent(keyboardEvent: IKeyboardEvent): ResolvedKeybinding {
566
this.keyboardLayoutService.validateCurrentKeyboardMapping(keyboardEvent);
567
return this._keyboardMapper.resolveKeyboardEvent(keyboardEvent);
568
}
569
570
public resolveUserBinding(userBinding: string): ResolvedKeybinding[] {
571
const keybinding = KeybindingParser.parseKeybinding(userBinding);
572
return (keybinding ? this._keyboardMapper.resolveKeybinding(keybinding) : []);
573
}
574
575
private _handleKeybindingsExtensionPointUser(extensionId: ExtensionIdentifier, isBuiltin: boolean, keybindings: ContributedKeyBinding | ContributedKeyBinding[], collector: ExtensionMessageCollector, result: IExtensionKeybindingRule[]): void {
576
if (Array.isArray(keybindings)) {
577
for (let i = 0, len = keybindings.length; i < len; i++) {
578
this._handleKeybinding(extensionId, isBuiltin, i + 1, keybindings[i], collector, result);
579
}
580
} else {
581
this._handleKeybinding(extensionId, isBuiltin, 1, keybindings, collector, result);
582
}
583
}
584
585
private _handleKeybinding(extensionId: ExtensionIdentifier, isBuiltin: boolean, idx: number, keybindings: ContributedKeyBinding, collector: ExtensionMessageCollector, result: IExtensionKeybindingRule[]): void {
586
587
const rejects: string[] = [];
588
589
if (isValidContributedKeyBinding(keybindings, rejects)) {
590
const rule = this._asCommandRule(extensionId, isBuiltin, idx++, keybindings);
591
if (rule) {
592
result.push(rule);
593
}
594
}
595
596
if (rejects.length > 0) {
597
collector.error(nls.localize(
598
'invalid.keybindings',
599
"Invalid `contributes.{0}`: {1}",
600
keybindingsExtPoint.name,
601
rejects.join('\n')
602
));
603
}
604
}
605
606
private static bindToCurrentPlatform(key: string | undefined, mac: string | undefined, linux: string | undefined, win: string | undefined): string | undefined {
607
if (OS === OperatingSystem.Windows && win) {
608
if (win) {
609
return win;
610
}
611
} else if (OS === OperatingSystem.Macintosh) {
612
if (mac) {
613
return mac;
614
}
615
} else {
616
if (linux) {
617
return linux;
618
}
619
}
620
return key;
621
}
622
623
private _asCommandRule(extensionId: ExtensionIdentifier, isBuiltin: boolean, idx: number, binding: ContributedKeyBinding): IExtensionKeybindingRule | undefined {
624
625
const { command, args, when, key, mac, linux, win } = binding;
626
const keybinding = WorkbenchKeybindingService.bindToCurrentPlatform(key, mac, linux, win);
627
if (!keybinding) {
628
return undefined;
629
}
630
631
let weight: number;
632
if (isBuiltin) {
633
weight = KeybindingWeight.BuiltinExtension + idx;
634
} else {
635
weight = KeybindingWeight.ExternalExtension + idx;
636
}
637
638
const commandAction = MenuRegistry.getCommand(command);
639
const precondition = commandAction && commandAction.precondition;
640
let fullWhen: ContextKeyExpression | undefined;
641
if (when && precondition) {
642
fullWhen = ContextKeyExpr.and(precondition, ContextKeyExpr.deserialize(when));
643
} else if (when) {
644
fullWhen = ContextKeyExpr.deserialize(when);
645
} else if (precondition) {
646
fullWhen = precondition;
647
}
648
649
const desc: IExtensionKeybindingRule = {
650
id: command,
651
args,
652
when: fullWhen,
653
weight: weight,
654
keybinding: KeybindingParser.parseKeybinding(keybinding),
655
extensionId: extensionId.value,
656
isBuiltinExtension: isBuiltin
657
};
658
return desc;
659
}
660
661
public override getDefaultKeybindingsContent(): string {
662
const resolver = this._getResolver();
663
const defaultKeybindings = resolver.getDefaultKeybindings();
664
const boundCommands = resolver.getDefaultBoundCommands();
665
return (
666
WorkbenchKeybindingService._getDefaultKeybindings(defaultKeybindings)
667
+ '\n\n'
668
+ WorkbenchKeybindingService._getAllCommandsAsComment(boundCommands)
669
);
670
}
671
672
private static _getDefaultKeybindings(defaultKeybindings: readonly ResolvedKeybindingItem[]): string {
673
const out = new OutputBuilder();
674
out.writeLine('[');
675
676
const lastIndex = defaultKeybindings.length - 1;
677
defaultKeybindings.forEach((k, index) => {
678
KeybindingIO.writeKeybindingItem(out, k);
679
if (index !== lastIndex) {
680
out.writeLine(',');
681
} else {
682
out.writeLine();
683
}
684
});
685
out.writeLine(']');
686
return out.toString();
687
}
688
689
private static _getAllCommandsAsComment(boundCommands: Map<string, boolean>): string {
690
const unboundCommands = getAllUnboundCommands(boundCommands);
691
const pretty = unboundCommands.sort().join('\n// - ');
692
return '// ' + nls.localize('unboundCommands', "Here are other available commands: ") + '\n// - ' + pretty;
693
}
694
695
override mightProducePrintableCharacter(event: IKeyboardEvent): boolean {
696
if (event.ctrlKey || event.metaKey || event.altKey) {
697
// ignore ctrl/cmd/alt-combination but not shift-combinatios
698
return false;
699
}
700
const code = ScanCodeUtils.toEnum(event.code);
701
702
if (NUMPAD_PRINTABLE_SCANCODES.indexOf(code) !== -1) {
703
// This is a numpad key that might produce a printable character based on NumLock.
704
// Let's check if NumLock is on or off based on the event's keyCode.
705
// e.g.
706
// - when NumLock is off, ScanCode.Numpad4 produces KeyCode.LeftArrow
707
// - when NumLock is on, ScanCode.Numpad4 produces KeyCode.NUMPAD_4
708
// However, ScanCode.NumpadAdd always produces KeyCode.NUMPAD_ADD
709
if (event.keyCode === IMMUTABLE_CODE_TO_KEY_CODE[code]) {
710
// NumLock is on or this is /, *, -, + on the numpad
711
return true;
712
}
713
if (isMacintosh && event.keyCode === otherMacNumpadMapping.get(code)) {
714
// on macOS, the numpad keys can also map to keys 1 - 0.
715
return true;
716
}
717
return false;
718
}
719
720
const keycode = IMMUTABLE_CODE_TO_KEY_CODE[code];
721
if (keycode !== -1) {
722
// https://github.com/microsoft/vscode/issues/74934
723
return false;
724
}
725
// consult the KeyboardMapperFactory to check the given event for
726
// a printable value.
727
const mapping = this.keyboardLayoutService.getRawKeyboardMapping();
728
if (!mapping) {
729
return false;
730
}
731
const keyInfo = mapping[event.code];
732
if (!keyInfo) {
733
return false;
734
}
735
if (!keyInfo.value || /\s/.test(keyInfo.value)) {
736
return false;
737
}
738
return true;
739
}
740
}
741
742
class UserKeybindings extends Disposable {
743
744
private _rawKeybindings: Object[] = [];
745
private _keybindings: IUserKeybindingItem[] = [];
746
get keybindings(): IUserKeybindingItem[] { return this._keybindings; }
747
748
private readonly reloadConfigurationScheduler: RunOnceScheduler;
749
750
private readonly watchDisposables = this._register(new DisposableStore());
751
752
private readonly _onDidChange: Emitter<void> = this._register(new Emitter<void>());
753
readonly onDidChange: Event<void> = this._onDidChange.event;
754
755
constructor(
756
private readonly userDataProfileService: IUserDataProfileService,
757
private readonly uriIdentityService: IUriIdentityService,
758
private readonly fileService: IFileService,
759
logService: ILogService,
760
) {
761
super();
762
763
this.watch();
764
765
this.reloadConfigurationScheduler = this._register(new RunOnceScheduler(() => this.reload().then(changed => {
766
if (changed) {
767
this._onDidChange.fire();
768
}
769
}), 50));
770
771
this._register(Event.filter(this.fileService.onDidFilesChange, e => e.contains(this.userDataProfileService.currentProfile.keybindingsResource))(() => {
772
logService.debug('Keybindings file changed');
773
this.reloadConfigurationScheduler.schedule();
774
}));
775
776
this._register(this.fileService.onDidRunOperation((e) => {
777
if (e.operation === FileOperation.WRITE && e.resource.toString() === this.userDataProfileService.currentProfile.keybindingsResource.toString()) {
778
logService.debug('Keybindings file written');
779
this.reloadConfigurationScheduler.schedule();
780
}
781
}));
782
783
this._register(userDataProfileService.onDidChangeCurrentProfile(e => {
784
if (!this.uriIdentityService.extUri.isEqual(e.previous.keybindingsResource, e.profile.keybindingsResource)) {
785
e.join(this.whenCurrentProfileChanged());
786
}
787
}));
788
}
789
790
private async whenCurrentProfileChanged(): Promise<void> {
791
this.watch();
792
this.reloadConfigurationScheduler.schedule();
793
}
794
795
private watch(): void {
796
this.watchDisposables.clear();
797
this.watchDisposables.add(this.fileService.watch(dirname(this.userDataProfileService.currentProfile.keybindingsResource)));
798
// Also listen to the resource incase the resource is a symlink - https://github.com/microsoft/vscode/issues/118134
799
this.watchDisposables.add(this.fileService.watch(this.userDataProfileService.currentProfile.keybindingsResource));
800
}
801
802
async initialize(): Promise<void> {
803
await this.reload();
804
}
805
806
private async reload(): Promise<boolean> {
807
const newKeybindings = await this.readUserKeybindings();
808
if (objects.equals(this._rawKeybindings, newKeybindings)) {
809
// no change
810
return false;
811
}
812
813
this._rawKeybindings = newKeybindings;
814
this._keybindings = this._rawKeybindings.map((k) => KeybindingIO.readUserKeybindingItem(k));
815
return true;
816
}
817
818
private async readUserKeybindings(): Promise<Object[]> {
819
try {
820
const content = await this.fileService.readFile(this.userDataProfileService.currentProfile.keybindingsResource);
821
const value = parse(content.value.toString());
822
return Array.isArray(value)
823
? value.filter(v => v && typeof v === 'object' /* just typeof === object doesn't catch `null` */)
824
: [];
825
} catch (e) {
826
return [];
827
}
828
}
829
}
830
831
/**
832
* 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).
833
*
834
* Lifecycle owned by `WorkbenchKeybindingService`. Must be instantiated only once.
835
*/
836
class KeybindingsJsonSchema {
837
838
private static readonly schemaId = 'vscode://schemas/keybindings';
839
840
private readonly commandsSchemas: IJSONSchema[] = [];
841
private readonly commandsEnum: string[] = [];
842
private readonly removalCommandsEnum: string[] = [];
843
private readonly commandsEnumDescriptions: string[] = [];
844
private readonly schema: IJSONSchema = {
845
id: KeybindingsJsonSchema.schemaId,
846
type: 'array',
847
title: nls.localize('keybindings.json.title', "Keybindings configuration"),
848
allowTrailingCommas: true,
849
allowComments: true,
850
definitions: {
851
'editorGroupsSchema': {
852
'type': 'array',
853
'items': {
854
'type': 'object',
855
'properties': {
856
'groups': {
857
'$ref': '#/definitions/editorGroupsSchema',
858
'default': [{}, {}]
859
},
860
'size': {
861
'type': 'number',
862
'default': 0.5
863
}
864
}
865
}
866
},
867
'commandNames': {
868
'type': 'string',
869
'enum': this.commandsEnum,
870
'enumDescriptions': this.commandsEnumDescriptions,
871
'description': nls.localize('keybindings.json.command', "Name of the command to execute"),
872
},
873
'commandType': {
874
'anyOf': [ // repetition of this clause here and below is intentional: one is for nice diagnostics & one is for code completion
875
{
876
$ref: '#/definitions/commandNames'
877
},
878
{
879
'type': 'string',
880
'enum': this.removalCommandsEnum,
881
'enumDescriptions': this.commandsEnumDescriptions,
882
'description': nls.localize('keybindings.json.removalCommand', "Name of the command to remove keyboard shortcut for"),
883
},
884
{
885
'type': 'string'
886
},
887
]
888
},
889
'commandsSchemas': {
890
'allOf': this.commandsSchemas
891
}
892
},
893
items: {
894
'required': ['key'],
895
'type': 'object',
896
'defaultSnippets': [{ 'body': { 'key': '$1', 'command': '$2', 'when': '$3' } }],
897
'properties': {
898
'key': {
899
'type': 'string',
900
'description': nls.localize('keybindings.json.key', "Key or key sequence (separated by space)"),
901
},
902
'command': {
903
'anyOf': [
904
{
905
'if': {
906
'type': 'array'
907
},
908
'then': {
909
'not': {
910
'type': 'array'
911
},
912
'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')
913
},
914
'else': {
915
'$ref': '#/definitions/commandType'
916
}
917
},
918
{
919
'$ref': '#/definitions/commandType'
920
}
921
]
922
},
923
'when': {
924
'type': 'string',
925
'description': nls.localize('keybindings.json.when', "Condition when the key is active.")
926
},
927
'args': {
928
'description': nls.localize('keybindings.json.args', "Arguments to pass to the command to execute.")
929
}
930
},
931
'$ref': '#/definitions/commandsSchemas'
932
}
933
};
934
935
private readonly schemaRegistry = Registry.as<IJSONContributionRegistry>(Extensions.JSONContribution);
936
937
constructor() {
938
this.schemaRegistry.registerSchema(KeybindingsJsonSchema.schemaId, this.schema);
939
}
940
941
// TODO@ulugbekna: can updates happen incrementally rather than rebuilding; concerns:
942
// - is just appending additional schemas enough for the registry to pick them up?
943
// - can `CommandsRegistry.getCommands` and `MenuRegistry.getCommands` return different values at different times? ie would just pushing new schemas from `additionalContributions` not be enough?
944
updateSchema(additionalContributions: readonly IJSONSchema[]) {
945
this.commandsSchemas.length = 0;
946
this.commandsEnum.length = 0;
947
this.removalCommandsEnum.length = 0;
948
this.commandsEnumDescriptions.length = 0;
949
950
const knownCommands = new Set<string>();
951
const addKnownCommand = (commandId: string, description?: string | ILocalizedString | undefined) => {
952
if (!/^_/.test(commandId)) {
953
if (!knownCommands.has(commandId)) {
954
knownCommands.add(commandId);
955
956
this.commandsEnum.push(commandId);
957
this.commandsEnumDescriptions.push(
958
description === undefined
959
? '' // `enumDescriptions` is an array of strings, so we can't use undefined
960
: (isLocalizedString(description) ? description.value : description)
961
);
962
963
// Also add the negative form for keybinding removal
964
this.removalCommandsEnum.push(`-${commandId}`);
965
}
966
}
967
};
968
969
const allCommands = CommandsRegistry.getCommands();
970
for (const [commandId, command] of allCommands) {
971
const commandMetadata = command.metadata;
972
973
addKnownCommand(commandId, commandMetadata?.description ?? MenuRegistry.getCommand(commandId)?.title);
974
975
if (!commandMetadata || !commandMetadata.args || commandMetadata.args.length !== 1 || !commandMetadata.args[0].schema) {
976
continue;
977
}
978
979
const argsSchema = commandMetadata.args[0].schema;
980
const argsRequired = (
981
(typeof commandMetadata.args[0].isOptional !== 'undefined')
982
? (!commandMetadata.args[0].isOptional)
983
: (Array.isArray(argsSchema.required) && argsSchema.required.length > 0)
984
);
985
const addition = {
986
'if': {
987
'required': ['command'],
988
'properties': {
989
'command': { 'const': commandId }
990
}
991
},
992
'then': {
993
'required': (<string[]>[]).concat(argsRequired ? ['args'] : []),
994
'properties': {
995
'args': argsSchema
996
}
997
}
998
};
999
1000
this.commandsSchemas.push(addition);
1001
}
1002
1003
const menuCommands = MenuRegistry.getCommands();
1004
for (const commandId of menuCommands.keys()) {
1005
addKnownCommand(commandId);
1006
}
1007
1008
this.commandsSchemas.push(...additionalContributions);
1009
this.schemaRegistry.notifySchemaChanged(KeybindingsJsonSchema.schemaId);
1010
}
1011
}
1012
1013
registerSingleton(IKeybindingService, WorkbenchKeybindingService, InstantiationType.Eager);
1014
1015