/********************************************************************************
 * 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 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 } from '@theia/core/shared/inversify' ; import { Emitter , Event } from '@theia/core/lib/common/event' ; import { CommandRegistry , Command } from '@theia/core/lib/common/command' ; import { ReactWidget } from '@theia/core/lib/browser/widgets/react-widget' ; import { KeybindingRegistry , SingleTextInputDialog , KeySequence , ConfirmDialog , Message , KeybindingScope , SingleTextInputDialogProps , Key , ScopedKeybinding , codicon } from '@theia/core/lib/browser' ; import { KeymapsService } from './keymaps-service' ; import { AlertMessage } from '@theia/core/lib/browser/widgets/alert-message' ; /**
 * Representation of a keybinding item for the view.
 */ export interface KeybindingItem { command : Command ; keybinding ? : ScopedKeybinding ; /** human-readable labels can contain highlighting */ labels : { id : string ; command : string ; keybinding : string ; context : string ; source : string ; } ; } /**
 * 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 { @ inject ( CommandRegistry ) protected readonly commandRegistry : CommandRegistry ; @ inject ( KeybindingRegistry ) protected readonly keybindingRegistry : KeybindingRegistry ; @ inject ( KeymapsService ) protected readonly keymapsService : KeymapsService ; static readonly ID = 'keybindings.view.widget' ; static readonly LABEL = 'Keyboard Shortcuts' ; /**
     * 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>(.*?)<\/match>/g ; /**
     * The regular expression used to extract values between the keybinding separator.
     */ protected readonly keybindingSeparator = /<match>\+<\/match>/g ; /**
     * The fuzzy search options.
     * The `pre` and `post` options are used to wrap fuzzy matches.
     */ protected readonly fuzzyOptions = { pre : '<match>' , post : '</match>' , } ; protected readonly onDidUpdateEmitter = new Emitter < void > ( ) ; readonly onDidUpdate : Event < void > = this . onDidUpdateEmitter . event ; /**
     * Search keybindings.
     */ protected readonly searchKeybindings : ( ) => void = debounce ( ( ) => this . doSearchKeybindings ( ) , 50 ) ; /**
     * Initialize the widget.
     */ @ postConstruct ( ) protected init ( ) { 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 . update ( ) ; this . items = this . getItems ( ) ; if ( this . keymapsService . onDidChangeKeymaps ) { this . toDispose . push ( this . keymapsService . onDidChangeKeymaps ( ( ) => { this . doSearchKeybindings ( ) ; this . update ( ) ; } ) ) ; } } returns public hasSearch ( ) { return ! ! this . query . length ; } /**
     * Clear the search and reset the view.
     */ public clearSearch ( ) { const search = this . findSearchField ( ) ; if ( search ) { search . value = '' ; this . query = '' ; this . doSearchKeybindings ( ) ; } } protected onActivateRequest ( msg ) { super . onActivateRequest ( msg ) ; this . focusInputField ( ) ; } /**
     * Perform a search based on the user's search query.
     */ protected doSearchKeybindings ( ) { this . onDidUpdateEmitter . fire ( undefined ) ; this . items = [ ] ; const searchField = this . findSearchField ( ) ; this . query = searchField ? searchField . value . trim ( ) . toLocaleLowerCase ( ) : '' ; const items = this . getItems ( ) ; items . forEach ( item => { const keys = [ 'command' , 'keybinding' , 'context' , 'source' ] ; let matched = false ; for ( const key of keys ) { const string = item . labels [ key ] ; if ( string ) { const fuzzyMatch = fuzzy . match ( this . query , string , this . fuzzyOptions ) ; if ( fuzzyMatch ) { item . labels [ key ] = fuzzyMatch . rendered ; matched = true ; } else { if ( key === 'keybinding' ) { const queryItems = this . query . split ( '+' ) ; const tempItems = string . split ( ' ' ) ; const spaceIndexArr = [ 0 ] ; let bindingItems = [ ] ; if ( tempItems . length > 1 ) { tempItems . forEach ( tItem => { const tKeys = tItem . split ( '+' ) ; spaceIndexArr . push ( tKeys . length + spaceIndexArr [ - 1 ] ) ; bindingItems . push ( ... tKeys ) ; } ) ; } else { bindingItems = string . split ( '+' ) ; } spaceIndexArr . shift ( ) ; const renderedResult = [ ... bindingItems ] ; let matchCounter = 0 ; queryItems . forEach ( queryItem => { let keyFuzzyMatch = { rendered : '' , score : 0 } ; let keyIndex = - 1 ; if ( string ) { bindingItems . forEach ( ( bindingItem ) => { const tempFuzzyMatch = fuzzy . match ( queryItem , bindingItem , this . fuzzyOptions ) ; if ( tempFuzzyMatch && tempFuzzyMatch . score > keyFuzzyMatch . score ) { keyFuzzyMatch = tempFuzzyMatch ; keyIndex = renderedResult . indexOf ( bindingItem ) ; } } ) ; const keyRendered = keyFuzzyMatch . rendered ; if ( keyRendered ) { if ( keyIndex > - 1 ) { renderedResult [ keyIndex ] = keyRendered ; } bindingItems . splice ( keyIndex , 1 , '' ) ; matchCounter += 1 ; } } } ) ; if ( matchCounter === queryItems . length ) { if ( spaceIndexArr . length > 0 ) { const chordRenderedResult = '' ; renderedResult . forEach ( ( resultKey , index ) => { if ( index === 0 ) { chordRenderedResult . concat ( resultKey ) ; } else if ( spaceIndexArr . indexOf ( index ) !== - 1 ) { chordRenderedResult . concat ( ' ' + resultKey ) ; } else { chordRenderedResult . concat ( '+' + resultKey ) ; } } ) ; item . labels [ key ] = chordRenderedResult ; } item . labels [ key ] = renderedResult . join ( '+' ) ; matched = true ; } } } } } if ( matched ) { this . items . push ( item ) ; } } ) ; this . update ( ) ; } returns protected findSearchField ( ) { return document . getElementById ( 'search-kb' ) as HTMLInputElement ; } /**
     * Set the focus the search input field if available.
     */ protected focusInputField ( ) { const input = document . getElementById ( 'search-kb' ) ; if ( input ) { ( input as HTMLInputElement ) . focus ( ) ; ( input as HTMLInputElement ) . select ( ) ; } } /**
     * Render the view.
     */ protected render ( ) { return < div id = 'kb-main-container' > { this . renderSearch ( ) } { ( this . items . length > 0 ) ? this . renderTable ( ) : this . renderMessage ( ) } < /div>; } /**
     * Render the search container with the search input.
     */ protected renderSearch ( ) { return < div > < div className = 'search-kb-container' > < input id = 'search-kb' className = { `theia-input${ ( this . items . length > 0 ) ? '' : ' no-kb' }` } type = 'text' spellCheck = { false } placeholder = 'Search keybindings' autoComplete = 'off' onKeyUp = { this . searchKeybindings } / > < /div> < /div>; } protected renderMessage ( ) { return < AlertMessage type = 'WARNING' header = 'No results found!' / > ; } protected renderTable ( ) { return < div id = 'kb-table-container' > < div className = 'kb' > < table > < thead > < tr > < th className = 'th-action' > < /th> < th className = 'th-label' > Command < /th> < th className = 'th-keybinding' > Keybinding < /th> < th className = 'th-context' > Context / When < /th> < th className = 'th-source' > Source < /th> < /tr> < /thead> < tbody > { this . renderRows ( ) } < /tbody> < /table> < /div> < /div>; } protected renderRows ( ) { return < React . Fragment > { this . items . map ( ( item , index ) => this . renderRow ( item , index ) ) } < /React.Fragment>; } protected renderRow ( item , index ) { const { command , keybinding } = item ; return < tr className = 'kb-item-row' key = { index } onDoubleClick = { ( ) => this . editKeybinding ( item ) } > < td className = 'kb-actions' > { this . renderActions ( item ) } < /td> < td className = 'kb-label' title = { this . getCommandLabel ( command ) } > { this . renderMatchedData ( item . labels . command ) } < /td> < td title = { this . getKeybindingLabel ( keybinding ) } className = 'kb-keybinding monaco-keybinding' > { this . renderKeybinding ( item . labels . keybinding ) } < /td> < td className = 'kb-context' title = { this . getContextLabel ( keybinding ) } > < code > { this . renderMatchedData ( item . labels . context ) } < /code> < /td> < td className = 'kb-source' title = { this . getScopeLabel ( keybinding ) } > < code className = 'td-source' > { this . renderMatchedData ( item . labels . source ) } < /code> < /td> < /tr>; } protected renderActions ( item ) { return < span className = 'kb-actions-icons' > { this . renderEdit ( item ) } { this . renderReset ( item ) } < /span>; } protected renderEdit ( item ) { return < a title = 'Edit Keybinding' href = '#' onClick = { e => { e . preventDefault ( ) ; this . editKeybinding ( item ) ; } } > < i className = { `${ codicon ( 'edit' ) } kb-action-item` } > < /i></a > ; } protected renderReset ( item ) { return ( item . keybinding && item . keybinding . scope === KeybindingScope . USER ) ? < a title = 'Reset Keybinding' href = '#' onClick = { e => { e . preventDefault ( ) ; this . resetKeybinding ( item ) ; } } > < i className = { `${ codicon ( 'discard' ) } kb-action-item` } > < /i></a > : '' ; } protected renderKeybinding ( keybinding ) { if ( ! keybinding . length ) { return undefined ; } const regex = new RegExp ( this . keybindingSeparator ) ; keybinding = keybinding . replace ( regex , '+' ) ; const keys = keybinding . split ( '+' ) ; return < React . Fragment > { keys . map ( ( key , index ) => { if ( index === 0 ) { return < span key = { index } className = 'monaco-keybinding-key' > { this . renderMatchedData ( key ) } < /span>; } else if ( key . includes ( ' ' ) ) { let chordKeys = key . split ( '<match> </match>' ) ; if ( chordKeys . length === 1 ) { chordKeys = key . split ( ' ' ) ; } return < React . Fragment key = { index } > < span className = 'monaco-keybinding-separator' > + < /span> < span className = 'monaco-keybinding-key' > { this . renderKeybinding ( chordKeys [ 0 ] ) } < /span> < span className = 'monaco-keybinding-separator' > & nbsp ; & nbsp ; < /span> < span className = 'monaco-keybinding-key' > { this . renderKeybinding ( chordKeys [ 1 ] ) } < /span> < /React.Fragment>; } else { return < React . Fragment key = { index } > < span className = 'monaco-keybinding-separator' > + < /span> < span className = 'monaco-keybinding-key' > { this . renderKeybinding ( key ) } < /span> < /React.Fragment>; } } ) } < /React.Fragment>; } protected getItems ( ) { const commands = this . commandRegistry . commands ; const items = [ ] ; for ( let i = 0 ; i < commands . length ; i ++ ) { const command = commands [ i ] ; if ( command . id . startsWith ( '_' ) ) { continue ; } const keybinding = this . keybindingRegistry . getKeybindingsForCommand ( command . id ) [ 0 ] ; items . push ( { command , keybinding , labels : { id : command . id , command : this . getCommandLabel ( command ) , keybinding : this . getKeybindingLabel ( keybinding ) || '' , context : this . getContextLabel ( keybinding ) || '' , source : this . getScopeLabel ( keybinding ) || '' } } ) ; } const sorted = items . sort ( ( a , b ) => this . compareItem ( a . command , b . command ) ) ; const keyItems = sorted . filter ( a => ! ! a . labels . keybinding ) ; const otherItems = sorted . filter ( a => ! a . labels . keybinding ) ; return [ ... keyItems , ... otherItems ] ; } protected getCommandLabel ( command ) { if ( command . label ) { return command . category ? `${ command . category }: ${ command . label }` : command . label ; } return command . id ; } protected getKeybindingLabel ( keybinding ) { return keybinding && keybinding . keybinding ; } protected getContextLabel ( keybinding ) { return keybinding ? keybinding . context || keybinding . when : undefined ; } protected getScopeLabel ( keybinding ) { let scope = keybinding && keybinding . scope ; if ( scope !== undefined ) { if ( scope < KeybindingScope . USER ) { scope = KeybindingScope . DEFAULT ; } return KeybindingScope [ scope ] . toLocaleLowerCase ( ) ; } return undefined ; } protected compareItem ( a , b ) { 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 ( ) ) ; } protected editKeybinding ( item ) { const command = item . command . id ; const oldKeybinding = item . keybinding && item . keybinding . keybinding ; const dialog = new EditKeybindingDialog ( { title : `Edit Keybinding for ${ command }` , initialValue : oldKeybinding , validate : newKeybinding => this . validateKeybinding ( command , oldKeybinding , newKeybinding ) , } , this . keymapsService , item ) ; dialog . open ( ) . then ( async ( keybinding ) => { if ( keybinding ) { await this . keymapsService . setKeybinding ( { ... item . keybinding , command , keybinding } , oldKeybinding ) ; } } ) ; } protected async confirmResetKeybinding ( item ) { const dialog = new ConfirmDialog ( { title : `Reset keybinding for '${ this . getCommandLabel ( item . command ) }'` , msg : 'Do you really want to reset this keybinding to its default value?' } ) ; return ! ! await dialog . open ( ) ; } protected async resetKeybinding ( item ) { const confirmed = await this . confirmResetKeybinding ( item ) ; if ( confirmed ) { this . keymapsService . removeKeybinding ( item . command . id ) ; } } protected validateKeybinding ( command , oldKeybinding , keybinding ) { if ( ! keybinding ) { return 'keybinding value is required' ; } try { const binding = { command , keybinding } ; KeySequence . parse ( keybinding ) ; if ( oldKeybinding === keybinding ) { return ' ' ; } if ( this . keybindingRegistry . containsKeybindingInScope ( binding ) ) { return 'keybinding currently collides' ; } return '' ; } catch ( error ) { return error ; } } protected buildCellData ( raw ) { const data = [ ] ; 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 ; } protected renderMatchedData ( property ) { if ( this . query !== '' ) { const cellData = this . buildCellData ( property ) ; return < React . Fragment > { cellData . map ( ( data , index ) => ( data . highlighted ) ? < span key = { index } className = 'fuzzy-match' > { data . value } < /span> : <span key={index}>{data.value}</span > ) } < /React.Fragment>; } else { return property ; } } } /**
 * 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 ; constructor ( @ inject ( SingleTextInputDialogProps ) protected readonly props , @ inject ( KeymapsService ) protected readonly keymapsService , item ) { super ( props ) ; this . item = item ; if ( this . item . keybinding && this . item . keybinding . scope === KeybindingScope . USER ) { this . appendResetButton ( ) ; } } protected onAfterAttach ( msg ) { super . onAfterAttach ( msg ) ; if ( this . resetButton ) { this . addResetAction ( this . resetButton , 'click' ) ; } } param element param additionalEventTypes protected addResetAction < K extends keyof HTMLElementEventMap > ( element , ... additionalEventTypes ) { this . addKeyListener ( element , Key . ENTER , ( ) => { this . reset ( ) ; this . close ( ) ; } , ... additionalEventTypes ) ; } returns protected appendResetButton ( ) { this . resetButton = this . createButton ( 'Reset' ) ; this . controlPanel . insertBefore ( this . resetButton , this . acceptButton ! ) ; this . resetButton . title = 'Reset Keybinding' ; this . resetButton . classList . add ( 'secondary' ) ; return this . resetButton ; } /**
     * Perform keybinding reset.
     */ protected reset ( ) { this . keymapsService . removeKeybinding ( this . item . command . id ) ; } }