// ***************************************************************************** // Copyright (C) 2018 Ericsson and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import React = require('@theia/core/shared/react'); import debounce = require('@theia/core/shared/lodash.debounce'); import * as fuzzy from '@theia/core/shared/fuzzy'; import { injectable, inject, postConstruct, unmanaged } from '@theia/core/shared/inversify'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { CommandRegistry, Command } from '@theia/core/lib/common/command'; import { Keybinding } from '@theia/core/lib/common/keybinding'; import { findSubstringIndex, matchRank } from '@theia/core/lib/common/fuzzy-match-utils'; import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget'; import { KeybindingRegistry, SingleTextInputDialog, KeySequence, ConfirmDialog, Message, KeybindingScope, SingleTextInputDialogProps, Key, ScopedKeybinding, codicon, StatefulWidget, Widget, ContextMenuRenderer, SELECTED_CLASS, KeyCode } from '@theia/core/lib/browser'; import { KeymapsService } from './keymaps-service'; import { AlertMessage } from '@theia/core/lib/browser/widgets/alert-message'; import { DisposableCollection, Disposable, isOSX, isObject } from '@theia/core'; import { nls } from '@theia/core/lib/common/nls'; /** * Representation of a keybinding item for the view. */ export interface KeybindingItem { command: Command keybinding?: ScopedKeybinding /** human-readable labels can contain highlighting */ labels: { id: RenderableLabel; command: RenderableLabel; keybinding: RenderableLabel; context: RenderableLabel; source: RenderableLabel; } visible?: boolean; } export namespace KeybindingItem { export function is(arg: unknown): arg is KeybindingItem { return isObject(arg) && 'command' in arg && 'labels' in arg; } export function keybinding(item: KeybindingItem): Keybinding { return item.keybinding ?? { command: item.command.id, keybinding: '' }; } } export interface RenderableLabel { readonly value: string; segments?: RenderableStringSegment[]; } export interface RenderableStringSegment { value: string; match: boolean; key?: boolean; } /** * Representation of an individual table cell. */ export interface CellData { /** * The cell value. */ value: string, /** * Indicates if a cell's value is currently highlighted. */ highlighted: boolean, } @injectable() export class KeybindingWidget extends ReactWidget implements StatefulWidget { @inject(CommandRegistry) protected readonly commandRegistry: CommandRegistry; @inject(KeybindingRegistry) protected readonly keybindingRegistry: KeybindingRegistry; @inject(KeymapsService) protected readonly keymapsService: KeymapsService; @inject(ContextMenuRenderer) protected readonly contextMenuRenderer: ContextMenuRenderer; static readonly ID = 'keybindings.view.widget'; static readonly LABEL = nls.localizeByDefault('Keyboard Shortcuts'); static readonly CONTEXT_MENU = ['keybinding-context-menu']; static readonly COPY_MENU = [...KeybindingWidget.CONTEXT_MENU, 'a_copy']; static readonly EDIT_MENU = [...KeybindingWidget.CONTEXT_MENU, 'b_edit']; static readonly ADD_MENU = [...KeybindingWidget.CONTEXT_MENU, 'c_add']; static readonly REMOVE_MENU = [...KeybindingWidget.CONTEXT_MENU, 'd_remove']; static readonly SHOW_MENU = [...KeybindingWidget.CONTEXT_MENU, 'e_show']; /** * The list of all available keybindings. */ protected items: KeybindingItem[] = []; /** * The current user search query. */ protected query: string = ''; /** * The regular expression used to extract values between fuzzy results. */ protected readonly regexp = /(.*?)<\/match>/g; /** * The regular expression used to extract values between the keybinding separator. */ protected readonly keybindingSeparator = /\+<\/match>/g; /** * The fuzzy search options. * The `pre` and `post` options are used to wrap fuzzy matches. */ protected readonly fuzzyOptions = { pre: '', post: '', }; protected readonly onDidUpdateEmitter = new Emitter(); readonly onDidUpdate: Event = this.onDidUpdateEmitter.event; protected readonly onRenderCallbacks = new DisposableCollection(); protected onRender = () => this.onRenderCallbacks.dispose(); /** * Search keybindings. */ protected readonly searchKeybindings: () => void = debounce(() => this.doSearchKeybindings(), 50); constructor(@unmanaged() options?: Widget.IOptions) { super(options); this.onRender = this.onRender.bind(this); } /** * Initialize the widget. */ @postConstruct() protected init(): void { this.id = KeybindingWidget.ID; this.title.label = KeybindingWidget.LABEL; this.title.caption = KeybindingWidget.LABEL; this.title.iconClass = codicon('three-bars'); this.title.closable = true; this.updateItemsAndRerender(); // Listen to changes made in the `keymaps.json` and update the view accordingly. if (this.keymapsService.onDidChangeKeymaps) { this.toDispose.push(this.keymapsService.onDidChangeKeymaps(() => { this.items = this.getItems(); this.doSearchKeybindings(); })); } this.toDispose.push(this.keybindingRegistry.onKeybindingsChanged(this.updateItemsAndRerender)); } protected updateItemsAndRerender = debounce(() => { this.items = this.getItems(); this.update(); if (this.hasSearch()) { this.doSearchKeybindings(); } }, 100, { leading: false, trailing: true }); /** * Determine if there currently is a search term. * @returns `true` if a search term is present. */ hasSearch(): boolean { return !!this.query.length; } /** * Clear the search and reset the view. */ clearSearch(): void { const search = this.findSearchField(); if (search) { search.value = ''; this.query = ''; this.doSearchKeybindings(); } } /** * Show keybinding items with the same key sequence as the given item. * @param item the keybinding item */ showSameKeybindings(item: KeybindingItem): void { const keybinding = item.keybinding; if (keybinding) { const search = this.findSearchField(); if (search) { const query = `"${this.keybindingRegistry.acceleratorFor(keybinding, '+', true).join(' ')}"`; search.value = query; this.query = query; this.doSearchKeybindings(); } } } protected override onActivateRequest(msg: Message): void { super.onActivateRequest(msg); this.focusInputField(); } /** * Perform a search based on the user's search query. */ protected doSearchKeybindings(): void { this.onDidUpdateEmitter.fire(undefined); const searchField = this.findSearchField(); this.query = searchField ? searchField.value.trim().toLocaleLowerCase() : ''; let query = this.query; const startsWithQuote = query.startsWith('"'); const endsWithQuote = query.endsWith('"'); const matchKeybindingOnly = startsWithQuote && endsWithQuote; if (startsWithQuote) { query = query.slice(1); } if (endsWithQuote) { query = query.slice(0, -1); } const queryItems = query.split(/[+\s]/); this.items.forEach(item => { let matched = !this.query; if (!matchKeybindingOnly) { matched = this.formatAndMatchCommand(item) || matched; } matched = this.formatAndMatchKeybinding(item, queryItems, matchKeybindingOnly) || matched; if (!matchKeybindingOnly) { matched = this.formatAndMatchContext(item) || matched; matched = this.formatAndMatchSource(item) || matched; } item.visible = matched; }); if (query) { this.items.sort((a, b) => { if (a.visible !== b.visible) { return a.visible ? -1 : 1; } if (a.visible && b.visible) { const aRank = matchRank(a.labels.command.value, query); const bRank = matchRank(b.labels.command.value, query); if (aRank !== bRank) { return aRank - bRank; } } return 0; }); } else { this.sortKeybindings(this.items); } this.update(); } protected formatAndMatchCommand(item: KeybindingItem): boolean { item.labels.command = this.toRenderableLabel(item.labels.command.value); return Boolean(item.labels.command.segments); } protected formatAndMatchKeybinding(item: KeybindingItem, queryItems: string[], exactMatch?: boolean): boolean { if (item.keybinding) { const unmatchedTerms = queryItems.filter(Boolean); const segments = this.keybindingRegistry.resolveKeybinding(item.keybinding).reduce((collection, code, codeIndex) => { if (codeIndex !== 0) { // Two non-breaking spaces. collection.push({ value: '\u00a0\u00a0', match: false, key: false }); } const displayChunks = this.keybindingRegistry.componentsForKeyCode(code); const matchChunks = isOSX ? this.keybindingRegistry.componentsForKeyCode(code, true) : displayChunks; displayChunks.forEach((chunk, chunkIndex) => { if (chunkIndex !== 0) { collection.push({ value: '+', match: false, key: false }); } const indexOfTerm = unmatchedTerms.indexOf(matchChunks[chunkIndex].toLocaleLowerCase()); const chunkMatches = indexOfTerm > -1; if (chunkMatches) { unmatchedTerms.splice(indexOfTerm, 1); } collection.push({ value: chunk, match: chunkMatches, key: true }); }); return collection; }, []); item.labels.keybinding = { value: item.labels.keybinding.value, segments }; if (unmatchedTerms.length) { return false; } if (exactMatch) { return !segments.some(segment => segment.key && !segment.match); } return true; } item.labels.keybinding = { value: '' }; return false; } protected formatAndMatchContext(item: KeybindingItem): boolean { item.labels.context = this.toRenderableLabel(item.labels.context.value); return Boolean(item.labels.context.segments); } protected formatAndMatchSource(item: KeybindingItem): boolean { item.labels.source = this.toRenderableLabel(item.labels.source.value); return Boolean(item.labels.source.segments); } protected toRenderableLabel(label: string, query: string = this.query): RenderableLabel { if (label && query) { // Prefer a contiguous substring highlight over scattered fuzzy character highlights. const substringIndex = findSubstringIndex(label, query); if (substringIndex !== -1) { const segments: RenderableStringSegment[] = []; if (substringIndex > 0) { segments.push({ value: label.substring(0, substringIndex), match: false }); } segments.push({ value: label.substring(substringIndex, substringIndex + query.length), match: true }); if (substringIndex + query.length < label.length) { segments.push({ value: label.substring(substringIndex + query.length), match: false }); } return { value: label, segments }; } const fuzzyMatch = fuzzy.match(query, label, this.fuzzyOptions); if (fuzzyMatch) { return { value: label, segments: fuzzyMatch.rendered.split(this.fuzzyOptions.pre).reduce((collection, segment) => { const [maybeMatch, notMatch] = segment.split(this.fuzzyOptions.post); if (notMatch === undefined) { collection.push({ value: maybeMatch, match: false }); } else { collection.push({ value: maybeMatch, match: true }, { value: notMatch, match: false }); } return collection; }, []) }; } } return { value: label }; } /** * Get the search input if available. * @returns the search input if available. */ protected findSearchField(): HTMLInputElement | null { return document.getElementById('search-kb') as HTMLInputElement; } /** * Set the focus the search input field if available. */ protected focusInputField(): void { const input = document.getElementById('search-kb'); if (input) { (input as HTMLInputElement).focus(); (input as HTMLInputElement).select(); } } /** * Render the view. */ protected render(): React.ReactNode { return
{this.renderSearch()} {(this.items.length > 0) ? this.renderTable() : this.renderMessage()}
; } /** * Render the search container with the search input. */ protected renderSearch(): React.ReactNode { return
0) ? '' : ' no-kb'}`} type='text' spellCheck={false} placeholder={nls.localizeByDefault('Type to search in keybindings')} autoComplete='off' onKeyUp={this.searchKeybindings} />
; } /** * Render the warning message when no search results are found. */ protected renderMessage(): React.ReactNode { return ; } /** * Render the keybindings table. */ protected renderTable(): React.ReactNode { return
{this.renderRows()}
{nls.localizeByDefault('Command')} {nls.localizeByDefault('Keybinding')} {nls.localizeByDefault('When')} {nls.localizeByDefault('Source')}
; } /** * Render the table rows. */ protected renderRows(): React.ReactNode { return {this.items.map((item, index) => item.visible !== false && this.renderRow(item, index))} ; } protected renderRow(item: KeybindingItem, index: number): React.ReactNode { const { command, keybinding } = item; // TODO get rid of array functions in event handlers return this.handleItemDoubleClick(item, index, event)} onClick={event => this.handleItemClick(item, index, event)} onContextMenu={event => this.handleItemContextMenu(item, index, event)}> {this.renderActions(item)} {this.renderMatchedData(item.labels.command)} {this.renderKeybinding(item)} {this.renderMatchedData(item.labels.context)} {this.renderMatchedData(item.labels.source)} ; } protected handleItemClick(item: KeybindingItem, index: number, event: React.MouseEvent): void { event.preventDefault(); this.selectItem(item, index, event.currentTarget); } protected handleItemDoubleClick(item: KeybindingItem, index: number, event: React.MouseEvent): void { event.preventDefault(); this.selectItem(item, index, event.currentTarget); this.editKeybinding(item); } protected handleItemContextMenu(item: KeybindingItem, index: number, event: React.MouseEvent): void { event.preventDefault(); this.selectItem(item, index, event.currentTarget); this.contextMenuRenderer.render({ menuPath: KeybindingWidget.CONTEXT_MENU, anchor: event.nativeEvent, args: [item, this], context: event.currentTarget }); } protected selectItem(item: KeybindingItem, index: number, element: HTMLElement): void { if (!element.classList.contains(SELECTED_CLASS)) { const selected = element.parentElement?.getElementsByClassName(SELECTED_CLASS)[0]; if (selected) { selected.classList.remove(SELECTED_CLASS); } element.classList.add(SELECTED_CLASS); } } /** * Render the actions container with action icons. * @param item the keybinding item for the row. */ protected renderActions(item: KeybindingItem): React.ReactNode { return {this.renderEdit(item)}{this.renderReset(item)}; } /** * Render the edit action used to update a keybinding. * @param item the keybinding item for the row. */ protected renderEdit(item: KeybindingItem): React.ReactNode { return { e.preventDefault(); this.editKeybinding(item); }}>; } /** * Render the reset action to reset the custom keybinding. * Only visible if a keybinding has a `user` scope. * @param item the keybinding item for the row. */ protected renderReset(item: KeybindingItem): React.ReactNode { return this.canResetKeybinding(item) ? { e.preventDefault(); this.resetKeybinding(item); }}> : ''; } /** * Render the keybinding. * @param keybinding the keybinding value. */ protected renderKeybinding(keybinding: KeybindingItem): React.ReactNode { if (!keybinding.keybinding) { return undefined; } if (keybinding.labels.keybinding.segments) { return keybinding.labels.keybinding.segments.map((segment, index) => { if (segment.key) { return {segment.value} ; } else { return {segment.value} ; } }); } console.warn('Unexpectedly encountered a keybinding without segment divisions'); return keybinding.labels.keybinding.value; } /** * Get the list of keybinding items. * * @returns the list of keybinding items. */ protected getItems(): KeybindingItem[] { // Sort the commands alphabetically. const commands = this.commandRegistry.commands; const items: KeybindingItem[] = []; // Build the keybinding items. for (let i = 0; i < commands.length; i++) { const command = commands[i]; // Skip internal commands prefixed by `_`. if (command.id.startsWith('_')) { continue; } const keybindings = this.keybindingRegistry.getKeybindingsForCommand(command.id); keybindings.forEach(keybinding => { const item = this.createKeybindingItem(command, keybinding); items.push(item); }); // we might not have any keybindings for the command if (keybindings.length < 1) { const item = this.createKeybindingItem(command); items.push(item); } } return this.sortKeybindings(items); } protected createKeybindingItem(command: Command, keybinding?: ScopedKeybinding): KeybindingItem { const item = { command, keybinding, labels: { id: { value: command.id }, command: { value: this.getCommandLabel(command) }, keybinding: { value: this.getKeybindingLabel(keybinding) || '' }, context: { value: this.getContextLabel(keybinding) || '' }, source: { value: this.getScopeLabel(keybinding) || '' } } }; this.formatAndMatchCommand(item); this.formatAndMatchKeybinding(item, []); this.formatAndMatchContext(item); this.formatAndMatchSource(item); return item; } /** * @returns the input array, sorted. * The sort priority is as follows: items with keybindings before those without, then alphabetical by command. */ protected sortKeybindings(bindings: KeybindingItem[]): KeybindingItem[] { return bindings.sort((a, b) => { if (a.keybinding && !b.keybinding) { return -1; } if (b.keybinding && !a.keybinding) { return 1; } return this.compareItem(a.command, b.command); }); } /** * Get the human-readable label for a given command. * @param command the command. * * @returns a human-readable label for the given command. */ protected getCommandLabel(command: Command): string { if (command.label) { // Prefix the command label with the category if it exists, else return the simple label. return command.category ? `${command.category}: ${command.label}` : command.label; } return command.id; } protected getKeybindingLabel(keybinding: ScopedKeybinding | undefined): string | undefined { return keybinding && keybinding.keybinding; } protected getContextLabel(keybinding: ScopedKeybinding | undefined): string | undefined { return keybinding ? keybinding.context || keybinding.when : undefined; } protected getScopeLabel(keybinding: ScopedKeybinding | undefined): string | undefined { let scope = keybinding && keybinding.scope; if (scope !== undefined) { if (scope < KeybindingScope.USER) { scope = KeybindingScope.DEFAULT; } return KeybindingScope[scope].toLocaleLowerCase(); } return undefined; } /** * Compare two commands. * - Commands with a label should be prioritized and alphabetically sorted. * - Commands without a label (id) should be placed at the bottom. * @param a the first command. * @param b the second command. * * @returns an integer indicating whether `a` comes before, after or is equivalent to `b`. * - returns `-1` if `a` occurs before `b`. * - returns `1` if `a` occurs after `b`. * - returns `0` if they are equivalent. */ protected compareItem(a: Command, b: Command): number { const labelA = this.getCommandLabel(a); const labelB = this.getCommandLabel(b); if (labelA === a.id && labelB === b.id) { return labelA.toLowerCase().localeCompare(labelB.toLowerCase()); } if (labelA === a.id) { return 1; } if (labelB === b.id) { return -1; } return labelA.toLowerCase().localeCompare(labelB.toLowerCase()); } /** * Prompt users to update the keybinding for the given command. * @param item the keybinding item. */ editKeybinding(item: KeybindingItem): void { const command = item.command.id; const oldKeybinding = item.keybinding; const dialog = new EditKeybindingDialog({ title: nls.localize('theia/keymaps/editKeybindingTitle', 'Edit Keybinding for {0}', item.labels.command.value), maxWidth: 400, initialValue: oldKeybinding?.keybinding, validate: (newKeybinding, mode) => mode === 'preview' && !newKeybinding ? '' : this.validateKeybinding(command, oldKeybinding?.keybinding, newKeybinding), }, this.keymapsService, item, this.canResetKeybinding(item)); dialog.open().then(async keybinding => { if (keybinding && keybinding !== oldKeybinding?.keybinding) { await this.keymapsService.setKeybinding({ ...oldKeybinding, command, keybinding }, oldKeybinding); } }); } /** * Prompt users to update when expression for the given keybinding. * @param item the keybinding item */ editWhenExpression(item: KeybindingItem): void { const keybinding = item.keybinding; if (!keybinding) { return; } const dialog = new SingleTextInputDialog({ title: nls.localize('theia/keymaps/editWhenExpressionTitle', 'Edit When Expression for {0}', item.labels.command.value), maxWidth: 400, initialValue: keybinding.when }); dialog.open().then(async when => { if (when === undefined) { return; // cancelled by the user } if (when !== (keybinding.when ?? '')) { if (when === '') { when = undefined; } await this.keymapsService.setKeybinding({ ...keybinding, when }, keybinding); } }); } /** * Prompt users to add a keybinding for the given command. * @param item the keybinding item */ addKeybinding(item: KeybindingItem): void { const command = item.command.id; const dialog = new EditKeybindingDialog({ title: nls.localize('theia/keymaps/addKeybindingTitle', 'Add Keybinding for {0}', item.labels.command.value), maxWidth: 400, validate: (newKeybinding, mode) => mode === 'preview' && !newKeybinding ? '' : this.validateKeybinding(command, undefined, newKeybinding), }, this.keymapsService, item, false); dialog.open().then(async keybinding => { if (keybinding) { await this.keymapsService.setKeybinding({ ...item.keybinding, command, keybinding }, undefined); } }); } /** * Prompt users for confirmation before resetting. * @param command the command label. * * @returns a Promise which resolves to `true` if a user accepts resetting. */ protected async confirmResetKeybinding(item: KeybindingItem): Promise { const message = document.createElement('div'); const question = document.createElement('p'); question.textContent = nls.localize('theia/keymaps/resetKeybindingConfirmation', 'Do you really want to reset this keybinding to its default value?'); message.append(question); const info = document.createElement('p'); info.textContent = nls.localize('theia/keymaps/resetMultipleKeybindingsWarning', 'If multiple keybindings exist for this command, all of them will be reset.'); message.append(info); const dialog = new ConfirmDialog({ title: nls.localize('theia/keymaps/resetKeybindingTitle', 'Reset keybinding for {0}', this.getCommandLabel(item.command)), msg: message }); return !!await dialog.open(); } /** * Reset the keybinding to its default value. * @param item the keybinding item. */ async resetKeybinding(item: KeybindingItem): Promise { const confirmed = await this.confirmResetKeybinding(item); if (confirmed) { this.keymapsService.removeKeybinding(item.command.id); } } /** * Whether the keybinding can be reset to its default value. * @param item the keybinding item */ canResetKeybinding(item: KeybindingItem): boolean { return item.keybinding?.scope === KeybindingScope.USER || this.keymapsService.hasKeybinding('-' + item.command.id); } /** * Validate the provided keybinding value against its previous value. * @param command the command label. * @param oldKeybinding the old keybinding value. * @param keybinding the new keybinding value. * * @returns the end user message to display. */ protected validateKeybinding(command: string, oldKeybinding: string | undefined, keybinding: string): string { if (!keybinding) { return nls.localize('theia/keymaps/requiredKeybindingValidation', 'keybinding value is required'); } try { const binding = { command, keybinding }; KeySequence.parse(keybinding); if (oldKeybinding === keybinding) { return ''; // if old and new keybindings match, quietly reject update } if (this.keybindingRegistry.containsKeybindingInScope(binding)) { return nls.localize('theia/keymaps/keybindingCollidesValidation', 'keybinding currently collides'); } return ''; } catch (error) { return error; } } /** * Build the cell data with highlights if applicable. * @param raw the raw cell value. * * @returns the list of cell data. */ protected buildCellData(raw: string): CellData[] { const data: CellData[] = []; if (this.query === '') { return data; } let following = raw; let leading; let result; const regexp = new RegExp(this.regexp); while (result = regexp.exec(raw)) { const splitLeftIndex = following.indexOf(result[0]); const splitRightIndex = splitLeftIndex + result[0].length; leading = following.slice(0, splitLeftIndex); following = following.slice(splitRightIndex); if (leading) { data.push({ value: leading, highlighted: false }); } data.push({ value: result[1], highlighted: true }); } if (following) { data.push({ value: following, highlighted: false }); } return data; } /** * Render the fuzzy representation of a matched result. * @param property one of the `KeybindingItem` properties. */ protected renderMatchedData(property: RenderableLabel): React.ReactNode { if (property.segments) { return <> { property.segments.map((segment, index) => segment.match ? {segment.value} : {segment.value}) } ; } return property.value; } storeState(): object | undefined { return { query: this.query }; } restoreState(oldState: { query: string }): void { if (typeof oldState?.query === 'string') { this.onRenderCallbacks.push({ dispose: () => { const searchField = this.findSearchField(); if (searchField) { searchField.value = oldState.query; this.searchKeybindings(); } } }); } } } /** * Dialog used to edit keybindings, and reset custom keybindings. */ class EditKeybindingDialog extends SingleTextInputDialog { /** * The keybinding item in question. */ protected item: KeybindingItem; /** * HTMLButtonElement used to reset custom keybindings. * Custom keybindings have a `User` scope (exist in `keymaps.json`). */ protected resetButton: HTMLButtonElement | undefined; // Tracks resources that need to be disposed of when the dialog closes. protected readonly keystrokeDisposable = new DisposableCollection(); protected chordPrefix: string | undefined; protected chordTimeout: number | undefined; constructor( @inject(SingleTextInputDialogProps) props: SingleTextInputDialogProps, @inject(KeymapsService) protected readonly keymapsService: KeymapsService, item: KeybindingItem, canReset: boolean ) { super(props); this.item = item; // Add the `Reset` button if the command currently has a custom keybinding. if (canReset) { this.appendResetButton(); } } protected override onAfterAttach(msg: Message): void { super.onAfterAttach(msg); if (this.resetButton) { this.addResetAction(this.resetButton, 'click'); } window.addEventListener('keydown', this.handleKeyDown, { capture: true }); this.keystrokeDisposable.push(Disposable.create(() => { window.removeEventListener('keydown', this.handleKeyDown, { capture: true }); })); setTimeout(() => { const inputField = this.node.querySelector('input'); if (inputField) { inputField.placeholder = nls.localizeByDefault('Press desired key combination and then press ENTER.'); } }, 100); } protected override onBeforeDetach(msg: Message): void { super.onBeforeDetach(msg); if (this.chordTimeout !== undefined) { window.clearTimeout(this.chordTimeout); this.chordTimeout = undefined; } this.keystrokeDisposable.dispose(); } /** * Intercepts keystrokes to format them into the expected keybinding string representation. * Prevents default browser text input for non-navigation keys. * @param event the keyboard event. */ protected handleKeyDown = (event: KeyboardEvent): void => { const target = event.target as HTMLElement; if (target.tagName.toLowerCase() !== 'input') { return; } // Only letting Enter and Escape bypass the capture if (event.key === 'Enter' || event.key === 'Escape') { return; } event.preventDefault(); event.stopPropagation(); const keyCode = KeyCode.createKeyCode(event); if (keyCode.isModifierOnly()) { return; } const inputField = target as HTMLInputElement; const keyString = keyCode.toString(); // Clear any existing timeout since a new key was pressed if (this.chordTimeout !== undefined) { window.clearTimeout(this.chordTimeout); this.chordTimeout = undefined; } if (this.chordPrefix) { // This is the second keystroke of a chord inputField.value = `${this.chordPrefix} ${keyString}`; this.chordPrefix = undefined; // Reset for the next full capture } else { // This is the first keystroke inputField.value = keyString; this.chordPrefix = keyString; // Wait 2 seconds for a potential second keystroke to form a chord this.chordTimeout = window.setTimeout(() => { this.chordPrefix = undefined; this.chordTimeout = undefined; }, 2000); } inputField.dispatchEvent(new window.Event('input', { bubbles: true })); }; /** * Add `Reset` action used to reset a custom keybinding, and close the dialog. * @param element the HTML element in question. * @param additionalEventTypes additional event types. */ protected addResetAction(element: HTMLElement, ...additionalEventTypes: K[]): void { this.addKeyListener(element, Key.ENTER, () => { this.reset(); this.close(); }, ...additionalEventTypes); } /** * Create the `Reset` button, and append it to the dialog. * * @returns the `Reset` button. */ protected appendResetButton(): HTMLButtonElement { // Create the `Reset` button. const resetButtonTitle = nls.localizeByDefault('Reset'); this.resetButton = this.createButton(resetButtonTitle); // Add the `Reset` button to the dialog control panel, before the `Accept` button. this.controlPanel.insertBefore(this.resetButton, this.acceptButton!); this.resetButton.title = nls.localizeByDefault('Reset Keybinding'); this.resetButton.classList.add('secondary'); return this.resetButton; } /** * Perform keybinding reset. */ protected reset(): void { this.keymapsService.removeKeybinding(this.item.command.id); } }