diff --git a/packages/monaco/src/browser/monaco-frontend-module.ts b/packages/monaco/src/browser/monaco-frontend-module.ts index 4a016fc2146df..20fc06ab9b1e2 100644 --- a/packages/monaco/src/browser/monaco-frontend-module.ts +++ b/packages/monaco/src/browser/monaco-frontend-module.ts @@ -39,6 +39,7 @@ import { MonacoDiffNavigatorFactory } from './monaco-diff-navigator-factory'; import { MonacoStrictEditorTextFocusContext } from './monaco-keybinding-contexts'; import { MonacoFrontendApplicationContribution } from './monaco-frontend-application-contribution'; import MonacoTextmateModuleBinder from './textmate/monaco-textmate-frontend-bindings'; +import { QuickInputService } from './monaco-quick-input-service'; decorate(injectable(), MonacoToProtocolConverter); decorate(injectable(), ProtocolToMonacoConverter); @@ -91,4 +92,6 @@ export default new ContainerModule((bind, unbind, isBound, rebind) => { ).inSingletonScope(); MonacoTextmateModuleBinder(bind, unbind, isBound, rebind); + + bind(QuickInputService).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..6d6c9ad341158 --- /dev/null +++ b/packages/monaco/src/browser/monaco-quick-input-service.ts @@ -0,0 +1,173 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. 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 { inject, injectable } from "inversify"; +import { MonacoQuickOpenService, MonacoQuickOpenControllerOptsImpl } from "./monaco-quick-open-service"; +import { QuickOpenMode, QuickOpenItemOptions, QuickOpenModel, QuickOpenOptions, QuickOpenItem } from "@theia/core/lib/browser"; + +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; +} + +const promptMessage = "Press 'Enter' to confirm your input or 'Escape' to cancel"; + +@injectable() +export class QuickInputService { + + private opts: MonacoQuickInputControllerOptsImpl; + @inject(MonacoQuickOpenService) + protected readonly quickOpenService: MonacoQuickOpenService; + + open(options: QuickInputOptions): Promise { + options.prompt = this.createPrompt(options.prompt); + + const inputItem = new InputOpenItemOptions(options.prompt); + this.opts = new MonacoQuickInputControllerOptsImpl({ + onType: (s, a) => this.validateInput(s, a, inputItem) + }, + options, + { + prefix: options.value, + placeholder: options.placeHolder, + onClose: () => inputItem.resolve(undefined) + }); + this.quickOpenService.internalOpen(this.opts); + + return new Promise(r => { + inputItem.resolve = r; + }); + } + + private createPrompt(prompt?: string): string { + if (prompt) { + return `${prompt} (${promptMessage})`; + } else { + return promptMessage; + } + } + + private validateInput(str: string, acceptor: (items: QuickOpenItem[]) => void, inputItem: InputOpenItemOptions): void { + inputItem.currentText = str; + acceptor([new QuickOpenItem(inputItem)]); + if (this.opts && this.opts.validateInput) { + const hint = this.opts.validateInput(str); + if (hint) { + if (typeof hint !== 'string') { + hint.then(p => { + if (p) { + this.setValidationState(inputItem, p, false); + } else { + this.setValidationState(inputItem, this.opts!.prompt!, true); + } + }); + } else { + this.setValidationState(inputItem, hint, false); + } + } else { + this.setValidationState(inputItem, this.opts.prompt!, true); + } + } + } + + private setValidationState(inputItem: InputOpenItemOptions, label: string, isValid: boolean): void { + this.quickOpenService.clearInputDecoration(); + inputItem.isValid = isValid; + inputItem.label = label; + // this.widget.refresh(); + if (isValid) { + this.quickOpenService.clearInputDecoration(); + } else { + this.quickOpenService.showInputDecoration(monaco.Severity.Error); + } + } +} + +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; + } +} + +class MonacoQuickInputControllerOptsImpl extends MonacoQuickOpenControllerOptsImpl { + readonly prompt?: string; + readonly password?: boolean; + readonly ignoreFocusOut?: boolean; + validateInput?(value: string): string | undefined | PromiseLike; + constructor( + model: QuickOpenModel, + inputOptions: QuickInputOptions, + options?: QuickOpenOptions + ) { + super(model, options); + if (inputOptions.password) { + this.password = inputOptions.password; + } + if (inputOptions.prompt) { + this.prompt = inputOptions.prompt; + } + + if (inputOptions.ignoreFocusOut) { + this.ignoreFocusOut = inputOptions.ignoreFocusOut; + } + if (inputOptions.validateInput) { + this.validateInput = inputOptions.validateInput; + } + } +} diff --git a/packages/monaco/src/browser/monaco-quick-open-service.ts b/packages/monaco/src/browser/monaco-quick-open-service.ts index e288675e1066e..7b11f83ccad12 100644 --- a/packages/monaco/src/browser/monaco-quick-open-service.ts +++ b/packages/monaco/src/browser/monaco-quick-open-service.ts @@ -24,6 +24,8 @@ import { ILogger } from '@theia/core'; export interface MonacoQuickOpenControllerOpts extends monaco.quickOpen.IQuickOpenControllerOpts { readonly prefix?: string; + readonly password?: boolean; + readonly ignoreFocusOut?: boolean; onType?(lookFor: string, acceptor: (model: monaco.quickOpen.QuickOpenModel) => void): void; onClose?(canceled: boolean): void; } @@ -59,6 +61,19 @@ export class MonacoQuickOpenService extends QuickOpenService { const widget = this.widget; widget.show(this.opts.prefix || ''); widget.setPlaceHolder(opts.inputAriaLabel); + if (opts.password) { + widget.setPassword(opts.password); + } else { + widget.setPassword(false); + } + } + + clearInputDecoration(): void { + this.widget.clearInputDecoration(); + } + + showInputDecoration(severity: monaco.Severity): void { + this.widget.showInputDecoration(severity); } protected get widget(): monaco.quickOpen.QuickOpenWidget { @@ -78,7 +93,7 @@ export class MonacoQuickOpenService extends QuickOpenService { this.onClose(true); }, onType: lookFor => this.onType(lookFor || ''), - onFocusLost: () => false + onFocusLost: () => (this.opts && this.opts.ignoreFocusOut !== undefined) ? this.opts.ignoreFocusOut : false }, {}); this.attachQuickOpenStyler(); this._widget.create(); diff --git a/packages/monaco/src/typings/monaco/index.d.ts b/packages/monaco/src/typings/monaco/index.d.ts index db5d3c80eb77f..6741a3e46b302 100644 --- a/packages/monaco/src/typings/monaco/index.d.ts +++ b/packages/monaco/src/typings/monaco/index.d.ts @@ -386,6 +386,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 4d5a59e8eeb87..d0e089e6579b8 100644 --- a/packages/plugin-ext/src/api/plugin-api.ts +++ b/packages/plugin-ext/src/api/plugin-api.ts @@ -97,14 +97,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 d974d2d0b86d9..87ac1d18c08a1 100644 --- a/packages/plugin-ext/src/main/browser/quick-open-main.ts +++ b/packages/plugin-ext/src/main/browser/quick-open-main.ts @@ -16,16 +16,18 @@ import { InputBoxOptions } from '@theia/plugin'; import { interfaces } from 'inversify'; -import { QuickOpenService } from '@theia/core/lib/browser/quick-open/quick-open-service'; 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 { MonacoQuickOpenService } from '@theia/monaco/lib/browser/monaco-quick-open-service'; +import { QuickInputService } from '@theia/monaco/lib/browser/monaco-quick-input-service'; export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { + private quickInput: QuickInputService; private doResolve: (value?: number | number[] | PromiseLike | undefined) => void; private proxy: QuickOpenExt; - private delegate: QuickOpenService; + private delegate: MonacoQuickOpenService; private acceptor: ((items: QuickOpenItem[]) => void) | undefined; private items: QuickOpenItem[] | undefined; @@ -33,7 +35,8 @@ export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { constructor(rpc: RPCProtocol, container: interfaces.Container) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.QUICK_OPEN_EXT); - this.delegate = container.get(QuickOpenService); + this.delegate = container.get(MonacoQuickOpenService); + this.quickInput = container.get(QuickInputService); } private cleanUp() { @@ -43,7 +46,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, @@ -61,7 +64,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({ @@ -85,11 +88,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.quickInput.open(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 c0460fcf1d889..ced79aa32aaef 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -144,6 +144,15 @@ 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); }, diff --git a/packages/plugin-ext/src/plugin/quick-open.ts b/packages/plugin-ext/src/plugin/quick-open.ts index 64c2da25295e2..112498331d24f 100644 --- a/packages/plugin-ext/src/plugin/quick-open.ts +++ b/packages/plugin-ext/src/plugin/quick-open.ts @@ -14,7 +14,7 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-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'; @@ -25,7 +25,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); @@ -35,7 +35,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)); } @@ -108,4 +108,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 9925caa8ecff1..4bd3c8fa34d31 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -1464,15 +1464,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; } /** @@ -1890,7 +1928,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. *