// ***************************************************************************** // Copyright (C) 2017 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 { inject, injectable, postConstruct, named } from '@theia/core/shared/inversify'; import { OpenerService, open, WidgetOpenerOptions, Widget } from '@theia/core/lib/browser'; import { KeybindingRegistry, KeybindingScope, ScopedKeybinding } from '@theia/core/lib/browser/keybinding'; import { Keybinding, RawKeybinding } from '@theia/core/lib/common/keybinding'; import { UserStorageUri } from '@theia/userstorage/lib/browser'; import * as jsoncparser from 'jsonc-parser'; import { Emitter } from '@theia/core/lib/common/event'; import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model'; import { Deferred } from '@theia/core/lib/common/promise-util'; import URI from '@theia/core/lib/common/uri'; import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace'; import { MessageService } from '@theia/core/lib/common/message-service'; import { MonacoJSONCEditor } from '@theia/preferences/lib/browser/monaco-jsonc-editor'; import { ILogger } from '@theia/core'; @injectable() export class KeymapsService { @inject(MonacoWorkspace) protected readonly workspace: MonacoWorkspace; @inject(MonacoTextModelService) protected readonly textModelService: MonacoTextModelService; @inject(KeybindingRegistry) protected readonly keybindingRegistry: KeybindingRegistry; @inject(OpenerService) protected readonly opener: OpenerService; @inject(MessageService) protected readonly messageService: MessageService; @inject(MonacoJSONCEditor) protected readonly jsoncEditor: MonacoJSONCEditor; @inject(ILogger) @named('keymaps:KeymapsService') protected readonly logger: ILogger; protected readonly changeKeymapEmitter = new Emitter(); readonly onDidChangeKeymaps = this.changeKeymapEmitter.event; protected model: MonacoEditorModel | undefined; protected readonly deferredModel = new Deferred(); /** * Initialize the keybinding service. */ @postConstruct() protected init(): void { this.doInit(); } protected async doInit(): Promise { const reference = await this.textModelService.createModelReference(UserStorageUri.resolve('keymaps.json')); this.model = reference.object; this.deferredModel.resolve(this.model); this.reconcile(); this.model.onDidChangeContent(() => this.reconcile()); this.model.onDirtyChanged(() => this.reconcile()); this.model.onDidChangeValid(() => this.reconcile()); this.keybindingRegistry.onKeybindingsChanged(() => this.changeKeymapEmitter.fire(undefined)); } /** * Reconcile all the keybindings, registering them to the registry. */ protected reconcile(): void { const model = this.model; if (!model || model.dirty) { return; } try { const keybindings: Keybinding[] = []; if (model.valid) { const content = model.getText(); const json = jsoncparser.parse(content, undefined, { disallowComments: false }); if (Array.isArray(json)) { for (const value of json) { if (Keybinding.is(value)) { keybindings.push(value); } else if (RawKeybinding.is(value)) { keybindings.push(Keybinding.apiObjectify(value)); } } } } this.keybindingRegistry.setKeymap(KeybindingScope.USER, keybindings); } catch (e) { this.logger.error(`Failed to load keymaps from '${model.uri}'.`, e); } } /** * Open the keybindings widget. * @param ref the optional reference for opening the widget. */ async open(ref?: Widget): Promise { const model = await this.deferredModel.promise; const options: WidgetOpenerOptions = { widgetOptions: ref ? { area: 'main', mode: 'split-right', ref } : { area: 'main' }, mode: 'activate' }; if (!model.valid) { await model.save(); } await open(this.opener, new URI(model.uri), options); } /** * Set the keybinding in the JSON. * @param newKeybinding the new JSON keybinding * @param oldKeybinding the old JSON keybinding */ async setKeybinding(newKeybinding: Keybinding, oldKeybinding: ScopedKeybinding | undefined): Promise { return this.updateKeymap(() => { const keybindings: Keybinding[] = [...this.keybindingRegistry.getKeybindingsByScope(KeybindingScope.USER)]; if (!oldKeybinding) { Keybinding.addKeybinding(keybindings, newKeybinding); return keybindings; } else if (oldKeybinding.scope === KeybindingScope.DEFAULT) { Keybinding.addKeybinding(keybindings, newKeybinding); const disabledBinding = { ...oldKeybinding, command: '-' + oldKeybinding.command }; Keybinding.addKeybinding(keybindings, disabledBinding); return keybindings; } else if (Keybinding.replaceKeybinding(keybindings, oldKeybinding, newKeybinding)) { return keybindings; } }); } /** * Unset the given keybinding in the JSON. * If the given keybinding has a default scope, it will be disabled in the JSON. * Otherwise, it will be removed from the JSON. * @param keybinding the keybinding to unset */ unsetKeybinding(keybinding: ScopedKeybinding): Promise { return this.updateKeymap(() => { const keybindings = this.keybindingRegistry.getKeybindingsByScope(KeybindingScope.USER); if (keybinding.scope === KeybindingScope.DEFAULT) { const result: Keybinding[] = [...keybindings]; const disabledBinding = { ...keybinding, command: '-' + keybinding.command }; Keybinding.addKeybinding(result, disabledBinding); return result; } else { const filtered = keybindings.filter(a => !Keybinding.equals(a, keybinding, false, true)); if (filtered.length !== keybindings.length) { return filtered; } } }); } /** * Whether there is a keybinding with the given command id in the JSON. * @param commandId the keybinding command id */ hasKeybinding(commandId: string): boolean { const keybindings = this.keybindingRegistry.getKeybindingsByScope(KeybindingScope.USER); return keybindings.some(a => a.command === commandId); } /** * Remove the keybindings with the given command id from the JSON. * This includes disabled keybindings. * @param commandId the keybinding command id. */ removeKeybinding(commandId: string): Promise { return this.updateKeymap(() => { const keybindings = this.keybindingRegistry.getKeybindingsByScope(KeybindingScope.USER); const removedCommand = '-' + commandId; const filtered = keybindings.filter(a => a.command !== commandId && a.command !== removedCommand); if (filtered.length !== keybindings.length) { return filtered; } }); } protected async updateKeymap(op: () => Keybinding[] | void): Promise { const model = await this.deferredModel.promise; try { const keybindings = op(); if (keybindings && this.model) { await this.jsoncEditor.setValue(this.model, [], keybindings.map(binding => Keybinding.apiObjectify(binding))); } } catch (e) { const message = `Failed to update a keymap in '${model.uri}'.`; this.messageService.error(`${message} Please check if it is corrupted.`); this.logger.error(`${message}`, e); } } }