From de479252487d138e212b2379b200d61124e25fbc Mon Sep 17 00:00:00 2001 From: Yevhen Vydolob Date: Fri, 22 Jun 2018 12:13:02 +0200 Subject: [PATCH] #2153 implement 'showInputBox' window API function Signed-off-by: Yevhen Vydolob --- .../src/browser/monaco-frontend-module.ts | 3 + .../src/browser/monaco-quick-input-service.ts | 163 ++++++++++++++++++ packages/monaco/src/typings/monaco/index.d.ts | 4 + packages/plugin-ext/src/api/plugin-api.ts | 10 +- .../src/main/browser/quick-open-main.ts | 18 +- .../plugin-ext/src/plugin/plugin-context.ts | 11 +- packages/plugin-ext/src/plugin/quick-open.ts | 13 +- packages/plugin/API.md | 19 ++ packages/plugin/src/theia.d.ts | 53 +++++- 9 files changed, 278 insertions(+), 16 deletions(-) create mode 100644 packages/monaco/src/browser/monaco-quick-input-service.ts diff --git a/packages/monaco/src/browser/monaco-frontend-module.ts b/packages/monaco/src/browser/monaco-frontend-module.ts index 8c87f81bc48e8..7aee40a0dd4cb 100644 --- a/packages/monaco/src/browser/monaco-frontend-module.ts +++ b/packages/monaco/src/browser/monaco-frontend-module.ts @@ -35,6 +35,7 @@ decorate(injectable(), ProtocolToMonacoConverter); import '../../src/browser/style/index.css'; import '../../src/browser/style/symbol-sprite.svg'; import '../../src/browser/style/symbol-icons.css'; +import { MonacoQuickInputService } from './monaco-quick-input-service'; export default new ContainerModule((bind, unbind, isBound, rebind) => { bind(MonacoToProtocolConverter).toSelf().inSingletonScope(); @@ -76,4 +77,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { rebind(QuickOpenService).toDynamicValue(ctx => ctx.container.get(MonacoQuickOpenService) ).inSingletonScope(); + + bind(MonacoQuickInputService).toSelf().inSingletonScope(); }); diff --git a/packages/monaco/src/browser/monaco-quick-input-service.ts b/packages/monaco/src/browser/monaco-quick-input-service.ts new file mode 100644 index 0000000000000..1271be2a72462 --- /dev/null +++ b/packages/monaco/src/browser/monaco-quick-input-service.ts @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2018 Red Hat, Inc. and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ +import { MonacoQuickOpenService } from "./monaco-quick-open-service"; +import { injectable, inject } from "inversify"; +import { ILogger } from "@theia/core/lib/common/logger"; +import { QuickOpenItem, QuickOpenItemOptions, QuickOpenMode } from "@theia/core/lib/browser/quick-open"; + +export interface QuickInputOptions { + + /** + * The prefill value. + */ + value?: string; + + /** + * The text to display under the input box. + */ + prompt?: string; + + /** + * The place holder in the input box to guide the user what to type. + */ + placeHolder?: string; + + /** + * Set to `true` to show a password prompt that will not show the typed value. + */ + password?: boolean; + + /** + * Set to `true` to keep the input box open when focus moves to another part of the editor or to another window. + */ + ignoreFocusOut?: boolean; + + /** + * An optional function that will be called to validate input and to give a hint + * to the user. + * + * @param value The current value of the input box. + * @return Return `undefined`, or the empty string when 'value' is valid. + */ + validateInput?(value: string): string | undefined | PromiseLike; +} + +@injectable() +export class MonacoQuickInputService extends MonacoQuickOpenService { + private static promptMessage = "Press 'Enter' to confirm your input or 'Escape' to cancel"; + private options: QuickInputOptions; + constructor(@inject(ILogger) logger: ILogger) { + super(logger); + + } + + input(options: QuickInputOptions): Promise { + this.options = options; + this.options.prompt = this.createPrompt(options.prompt); + + const inputItem = new InputOpenItemOptions(this.options.prompt); + this.open({ + onType: (s, a) => this.validateInput(s, a, inputItem) + }, { + prefix: options.value, + placeholder: options.placeHolder, + onClose: () => inputItem.resolve(undefined) + }); + if (options.password) { + this.widget.setPassword(true); + } + + return new Promise(r => { + inputItem.resolve = r; + }); + } + + private createPrompt(prompt?: string): string { + if (prompt) { + return `${prompt} (${MonacoQuickInputService.promptMessage})`; + } else { + return MonacoQuickInputService.promptMessage; + } + } + + private validateInput(str: string, acceptor: (items: QuickOpenItem[]) => void, inputItem: InputOpenItemOptions): void { + inputItem.currentText = str; + acceptor([new QuickOpenItem(inputItem)]); + if (this.options.validateInput) { + const hint = this.options.validateInput(str); + if (hint) { + if (typeof hint !== 'string') { + hint.then(p => { + if (p) { + this.setValidationState(inputItem, p, false); + } else { + this.setValidationState(inputItem, this.options.prompt!, true); + } + }); + } else { + this.setValidationState(inputItem, hint, false); + } + } else { + this.setValidationState(inputItem, this.options.prompt!, true); + } + } + } + + private setValidationState(inputItem: InputOpenItemOptions, label: string, isValid: boolean): void { + this.widget.clearInputDecoration(); + inputItem.isValid = isValid; + inputItem.label = label; + this.widget.refresh(); + if (isValid) { + this.widget.clearInputDecoration(); + } else { + this.widget.showInputDecoration(monaco.Severity.Error); + } + } + + protected get widget(): monaco.quickOpen.QuickOpenWidget { + if (this._widget) { + return this._widget; + } + this._widget = new monaco.quickOpen.QuickOpenWidget(this.container, { + onOk: () => { + this.previousActiveElement = undefined; + this.onClose(false); + }, + onCancel: () => { + if (this.previousActiveElement instanceof HTMLElement) { + this.previousActiveElement.focus(); + } + this.previousActiveElement = undefined; + this.onClose(true); + }, + onType: lookFor => this.onType(lookFor || ''), + onFocusLost: () => this.options.ignoreFocusOut !== undefined ? this.options.ignoreFocusOut : false + }, {}); + this.attachQuickOpenStyler(); + this._widget.create(); + return this._widget; + } +} + +class InputOpenItemOptions implements QuickOpenItemOptions { + currentText: string; + isValid = true; + resolve: (value?: string | PromiseLike | undefined) => void; + + constructor( + public label?: string) { + } + + run(mode: QuickOpenMode): boolean { + if (this.isValid && mode === QuickOpenMode.OPEN) { + this.resolve(this.currentText); + return true; + } + return false; + } +} diff --git a/packages/monaco/src/typings/monaco/index.d.ts b/packages/monaco/src/typings/monaco/index.d.ts index ea3f479ae4834..ff56255789547 100644 --- a/packages/monaco/src/typings/monaco/index.d.ts +++ b/packages/monaco/src/typings/monaco/index.d.ts @@ -377,6 +377,10 @@ declare module monaco.quickOpen { layout(dimension: monaco.editor.IDimension): void; show(prefix: string, options?: IShowOptions): void; hide(reason?: HideReason): void; + refresh(input?: IModel, autoFocus?: IAutoFocus): void; + setPassword(isPassword: boolean): void; + showInputDecoration(decoration: Severity): void; + clearInputDecoration(): void; } export enum HideReason { diff --git a/packages/plugin-ext/src/api/plugin-api.ts b/packages/plugin-ext/src/api/plugin-api.ts index 7eed430390744..a5b7d503823e7 100644 --- a/packages/plugin-ext/src/api/plugin-api.ts +++ b/packages/plugin-ext/src/api/plugin-api.ts @@ -88,14 +88,14 @@ export interface StatusBarMessageRegistryMain { export interface QuickOpenExt { $onItemSelected(handle: number): void; - $validateInput(input: string): PromiseLike | undefined; + $validateInput(input: string): PromiseLike | undefined; } export interface QuickOpenMain { - $show(options: PickOptions): PromiseLike; - $setItems(items: PickOpenItem[]): PromiseLike; - $setError(error: Error): PromiseLike; - $input(options: theia.InputBoxOptions, validateInput: boolean): PromiseLike; + $show(options: PickOptions): Promise; + $setItems(items: PickOpenItem[]): Promise; + $setError(error: Error): Promise; + $input(options: theia.InputBoxOptions, validateInput: boolean): Promise; } export interface WindowStateExt { diff --git a/packages/plugin-ext/src/main/browser/quick-open-main.ts b/packages/plugin-ext/src/main/browser/quick-open-main.ts index c8056f41443ae..53a63bcd25bd2 100644 --- a/packages/plugin-ext/src/main/browser/quick-open-main.ts +++ b/packages/plugin-ext/src/main/browser/quick-open-main.ts @@ -11,6 +11,7 @@ import { QuickOpenService } from '@theia/core/lib/browser/quick-open/quick-open- import { QuickOpenModel, QuickOpenItem, QuickOpenMode } from '@theia/core/lib/browser/quick-open/quick-open-model'; import { RPCProtocol } from '../../api/rpc-protocol'; import { QuickOpenExt, QuickOpenMain, MAIN_RPC_CONTEXT, PickOptions, PickOpenItem } from '../../api/plugin-api'; +import { MonacoQuickInputService } from '@theia/monaco/lib/browser/monaco-quick-input-service'; export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { @@ -21,10 +22,12 @@ export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { private items: QuickOpenItem[] | undefined; private activeElement: HTMLElement | undefined; + private input: MonacoQuickInputService; constructor(rpc: RPCProtocol, container: interfaces.Container) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.QUICK_OPEN_EXT); this.delegate = container.get(QuickOpenService); + this.input = container.get(MonacoQuickInputService); } private cleanUp() { @@ -34,7 +37,7 @@ export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { this.activeElement = undefined; } - $show(options: PickOptions): PromiseLike { + $show(options: PickOptions): Promise { this.activeElement = window.document.activeElement as HTMLElement; this.delegate.open(this, { fuzzyMatchDescription: options.matchOnDescription, @@ -52,7 +55,7 @@ export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { } // tslint:disable-next-line:no-any - $setItems(items: PickOpenItem[]): PromiseLike { + $setItems(items: PickOpenItem[]): Promise { this.items = []; for (const i of items) { this.items.push(new QuickOpenItem({ @@ -76,11 +79,16 @@ export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { return Promise.resolve(); } // tslint:disable-next-line:no-any - $setError(error: Error): PromiseLike { + $setError(error: Error): Promise { throw new Error("Method not implemented."); } - $input(options: InputBoxOptions, validateInput: boolean): PromiseLike { - throw new Error("Method not implemented."); + + $input(options: InputBoxOptions, validateInput: boolean): Promise { + if (validateInput) { + options.validateInput = val => this.proxy.$validateInput(val); + } + + return this.input.input(options); } onType(lookFor: string, acceptor: (items: QuickOpenItem[]) => void): void { diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 77a8135edbd3d..419306011f7a3 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -133,11 +133,20 @@ export function createAPI(rpc: RPCProtocol): typeof theia { setStatusBarMessage(text: string, arg?: number | PromiseLike): Disposable { return statusBarMessageRegistryExt.setStatusBarMessage(text, arg); }, + showInputBox(options?: theia.InputBoxOptions, token?: theia.CancellationToken) { + if (token) { + const coreEvent = Object.assign(token.onCancellationRequested, { maxListeners: 0 }); + const coreCancellationToken = { isCancellationRequested: token.isCancellationRequested, onCancellationRequested: coreEvent }; + return quickOpenExt.showInput(options, coreCancellationToken); + } else { + return quickOpenExt.showInput(options); + } + }, createStatusBarItem(alignment?: theia.StatusBarAlignment, priority?: number): theia.StatusBarItem { return statusBarMessageRegistryExt.createStatusBarItem(alignment, priority); }, createOutputChannel(name: string): theia.OutputChannel { - return outputChannelRegistryExt.createOutputChannel(name); + return outputChannelRegistryExt.createOutputChannel(name); }, get state(): theia.WindowState { diff --git a/packages/plugin-ext/src/plugin/quick-open.ts b/packages/plugin-ext/src/plugin/quick-open.ts index 10c52526502d3..b7a18f010a149 100644 --- a/packages/plugin-ext/src/plugin/quick-open.ts +++ b/packages/plugin-ext/src/plugin/quick-open.ts @@ -5,7 +5,7 @@ * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ import { QuickOpenExt, PLUGIN_RPC_CONTEXT as Ext, QuickOpenMain, PickOpenItem } from '../api/plugin-api'; -import { QuickPickOptions, QuickPickItem } from '@theia/plugin'; +import { QuickPickOptions, QuickPickItem, InputBoxOptions } from '@theia/plugin'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; import { RPCProtocol } from '../api/rpc-protocol'; import { ExtendedPromise } from '../api/extended-promise'; @@ -16,7 +16,7 @@ export type Item = string | QuickPickItem; export class QuickOpenExtImpl implements QuickOpenExt { private proxy: QuickOpenMain; private selectItemHandler: undefined | ((handle: number) => void); - private validateInputHandler: undefined | ((input: string) => string | PromiseLike); + private validateInputHandler: undefined | ((input: string) => string | PromiseLike | undefined); constructor(rpc: RPCProtocol) { this.proxy = rpc.getProxy(Ext.QUICK_OPEN_MAIN); @@ -26,7 +26,7 @@ export class QuickOpenExtImpl implements QuickOpenExt { this.selectItemHandler(handle); } } - $validateInput(input: string): PromiseLike | undefined { + $validateInput(input: string): PromiseLike | undefined { if (this.validateInputHandler) { return Promise.resolve(this.validateInputHandler(input)); } @@ -99,4 +99,11 @@ export class QuickOpenExtImpl implements QuickOpenExt { }); return hookCancellationToken(token, promise); } + + showInput(options?: InputBoxOptions, token: CancellationToken = CancellationToken.None): PromiseLike { + this.validateInputHandler = options && options.validateInput; + + const promise = this.proxy.$input(options!, typeof this.validateInputHandler === 'function'); + return hookCancellationToken(token, promise); + } } diff --git a/packages/plugin/API.md b/packages/plugin/API.md index 572039c6ad7b1..181861dd908bf 100644 --- a/packages/plugin/API.md +++ b/packages/plugin/API.md @@ -53,6 +53,25 @@ theia.window.showQuickPick(["foo", "bar", "foobar"], option).then((val: string[] }); ``` +#### Input Box + +Function to ask user for input. + +Example of using: + +```typescript +const option: theia.InputBoxOptions = { + prompt:"Hello from Plugin", + placeHolder:"Type text there", + ignoreFocusOut: false, + password: false, + value:"Default value" +}; +theia.window.showInputBox(option).then((s: string | undefined) => { + console.log(typeof s !== 'undefined'? s : "Input was canceled"); +}); +``` + #### Notification API A notification shows an information message to users. diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 0d11676193875..3c0a54c70b5a9 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -1455,15 +1455,53 @@ declare module '@theia/plugin' { onDidSelectItem?(item: QuickPickItem | string): any; } + /** + * Options to configure the behavior of the input box UI. + */ export interface InputBoxOptions { + + /** + * The value to prefill in the input box. + */ value?: string; + /** + * Selection of the prefilled [`value`](#InputBoxOptions.value). Defined as tuple of two number where the + * first is the inclusive start index and the second the exclusive end index. When `undefined` the whole + * word will be selected, when empty (start equals end) only the cursor will be set, + * otherwise the defined range will be selected. + */ valueSelection?: [number, number]; + + /** + * The text to display underneath the input box. + */ prompt?: string; + + /** + * An optional string to show as place holder in the input box to guide the user what to type. + */ placeHolder?: string; + + /** + * Set to `true` to show a password prompt that will not show the typed value. + */ password?: boolean; + + /** + * Set to `true` to keep the input box open when focus moves to another part of the editor or to another window. + */ ignoreFocusOut?: boolean; - validateInput?(value: string): string | undefined | null | PromiseLike; + + /** + * An optional function that will be called to validate input and to give a hint + * to the user. + * + * @param value The current value of the input box. + * @return A human readable string which is presented as diagnostic message. + * Return `undefined`, `null`, or the empty string when 'value' is valid. + */ + validateInput?(value: string): string | undefined | PromiseLike; } /** @@ -1881,7 +1919,18 @@ declare module '@theia/plugin' { * @return A promise that resolves to the selected item or `undefined` when being dismissed. */ export function showErrorMessage(message: string, options: MessageOptions, ...items: T[]): PromiseLike; - + /** + * Opens an input box to ask the user for input. + * + * The returned value will be `undefined` if the input box was canceled (e.g. pressing ESC). Otherwise the + * returned value will be the string typed by the user or an empty string if the user did not type + * anything but dismissed the input box with OK. + * + * @param options Configures the behavior of the input box. + * @param token A token that can be used to signal cancellation. + * @return A promise that resolves to a string the user provided or to `undefined` in case of dismissal. + */ + export function showInputBox(options?: InputBoxOptions, token?: CancellationToken): PromiseLike; /** * Represents the current window's state. *