diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index f2b16060d6138..853e60c402b8d 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -76,6 +76,7 @@ import { MimeService } from './mime-service'; import { ApplicationShellMouseTracker } from './shell/application-shell-mouse-tracker'; import { ViewContainer, ViewContainerIdentifier } from './view-container'; import { QuickViewService } from './quick-view-service'; +import { QuickTitleBar } from './quick-open/quick-title-bar'; export const frontendApplicationModule = new ContainerModule((bind, unbind, isBound, rebind) => { const themeService = ThemeService.get(); @@ -163,6 +164,7 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bind(QuickOpenService).toSelf().inSingletonScope(); bind(QuickInputService).toSelf().inSingletonScope(); + bind(QuickTitleBar).toSelf().inSingletonScope(); bind(QuickCommandService).toSelf().inSingletonScope(); bind(QuickCommandFrontendContribution).toSelf().inSingletonScope(); [CommandContribution, KeybindingContribution, MenuContribution].forEach(serviceIdentifier => diff --git a/packages/core/src/browser/quick-open/prefix-quick-open-service.ts b/packages/core/src/browser/quick-open/prefix-quick-open-service.ts index fe59357430a13..ce424f778031b 100644 --- a/packages/core/src/browser/quick-open/prefix-quick-open-service.ts +++ b/packages/core/src/browser/quick-open/prefix-quick-open-service.ts @@ -21,6 +21,7 @@ import { Disposable, DisposableCollection } from '../../common/disposable'; import { ILogger } from '../../common/logger'; import { MaybePromise } from '../../common/types'; import { QuickOpenActionProvider } from './quick-open-action-provider'; +import { QuickTitleBar } from './quick-title-bar'; export const QuickOpenContribution = Symbol('QuickOpenContribution'); /** @@ -141,6 +142,9 @@ export class PrefixQuickOpenService { @inject(QuickOpenService) protected readonly quickOpenService: QuickOpenService; + @inject(QuickTitleBar) + protected readonly quickTitleBar: QuickTitleBar; + /** * Opens a quick open widget with the model that handles the known prefixes. * @param prefix string that may contain a prefix of some of the known quick open handlers. @@ -188,6 +192,9 @@ export class PrefixQuickOpenService { } protected doOpen(options?: QuickOpenOptions): void { + if (this.quickTitleBar.isAttached) { + this.quickTitleBar.hide(); + } this.quickOpenService.open({ onType: (lookFor, acceptor) => this.onType(lookFor, acceptor) }, options); diff --git a/packages/core/src/browser/quick-open/quick-input-service.ts b/packages/core/src/browser/quick-open/quick-input-service.ts index 992288dedeaed..a3cbc41efa7b2 100644 --- a/packages/core/src/browser/quick-open/quick-input-service.ts +++ b/packages/core/src/browser/quick-open/quick-input-service.ts @@ -20,8 +20,46 @@ import { QuickOpenItem, QuickOpenMode } from './quick-open-model'; import { Deferred } from '../../common/promise-util'; import { MaybePromise } from '../../common/types'; import { MessageType } from '../../common/message-service-protocol'; +import { Emitter, Event } from '../../common/event'; +import { QuickTitleBar, QuickInputTitleButton } from './quick-title-bar'; export interface QuickInputOptions { + + /** + * Show the progress indicator if true + */ + busy?: boolean + + /** + * Allow user input + */ + enabled?: boolean; + + /** + * Current step count + */ + step?: number | undefined + + /** + * The title of the input + */ + title?: string | undefined + + /** + * Total number of steps + */ + totalSteps?: number | undefined + + /** + * Buttons that are displayed on the title panel + */ + buttons?: ReadonlyArray + + /** + * Text for when there is a problem with the current input value + */ + validationMessage?: string | undefined; + /** * The prefill value. */ @@ -47,6 +85,14 @@ export interface QuickInputOptions { */ ignoreFocusOut?: boolean; + /** + * 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] + /** * An optional function that will be called to validate input and to give a hint * to the user. @@ -63,15 +109,24 @@ export class QuickInputService { @inject(QuickOpenService) protected readonly quickOpenService: QuickOpenService; + @inject(QuickTitleBar) + protected readonly quickTitleBar: QuickTitleBar; + open(options: QuickInputOptions): Promise { const result = new Deferred(); const prompt = this.createPrompt(options.prompt); let label = prompt; let currentText = ''; const validateInput = options && options.validateInput; + + if (options && this.quickTitleBar.shouldShowTitleBar(options.title, options.step)) { + this.quickTitleBar.attachTitleBar(this.quickOpenService.widgetNode, options.title, options.step, options.totalSteps, options.buttons); + } + this.quickOpenService.open({ onType: async (lookFor, acceptor) => { - const error = validateInput ? await validateInput(lookFor) : undefined; + this.onDidChangeValueEmitter.fire(lookFor); + const error = validateInput && lookFor !== undefined ? await validateInput(lookFor) : undefined; label = error || prompt; if (error) { this.quickOpenService.showDecoration(MessageType.Error); @@ -83,6 +138,8 @@ export class QuickInputService { run: mode => { if (!error && mode === QuickOpenMode.OPEN) { result.resolve(currentText); + this.onDidAcceptEmitter.fire(undefined); + this.quickTitleBar.hide(); return true; } return false; @@ -95,14 +152,33 @@ export class QuickInputService { placeholder: options.placeHolder, password: options.password, ignoreFocusOut: options.ignoreFocusOut, - onClose: () => result.resolve(undefined) + enabled: options.enabled, + valueSelection: options.valueSelection, + onClose: () => { + result.resolve(undefined); + this.quickTitleBar.hide(); + } }); return result.promise; } + refresh() { + this.quickOpenService.refresh(); + } + protected defaultPrompt = "Press 'Enter' to confirm your input or 'Escape' to cancel"; protected createPrompt(prompt?: string): string { return prompt ? `${prompt} (${this.defaultPrompt})` : this.defaultPrompt; } + readonly onDidAcceptEmitter: Emitter = new Emitter(); + get onDidAccept(): Event { + return this.onDidAcceptEmitter.event; + } + + readonly onDidChangeValueEmitter: Emitter = new Emitter(); + get onDidChangeValue(): Event { + return this.onDidChangeValueEmitter.event; + } + } diff --git a/packages/core/src/browser/quick-open/quick-open-service.ts b/packages/core/src/browser/quick-open/quick-open-service.ts index 911f54c60763d..41566616cbed1 100644 --- a/packages/core/src/browser/quick-open/quick-open-service.ts +++ b/packages/core/src/browser/quick-open/quick-open-service.ts @@ -33,4 +33,10 @@ export class QuickOpenService { hide(reason?: common.QuickOpenHideReason): void { } showDecoration(type: MessageType): void { } hideDecoration(): void { } + refresh(): void { } + + /** + * Dom node of the QuickOpenWidget + */ + widgetNode: HTMLElement; } diff --git a/packages/core/src/browser/quick-open/quick-pick-service-impl.ts b/packages/core/src/browser/quick-open/quick-pick-service-impl.ts index 8266d47120cbd..0fcfd3665f860 100644 --- a/packages/core/src/browser/quick-open/quick-pick-service-impl.ts +++ b/packages/core/src/browser/quick-open/quick-pick-service-impl.ts @@ -19,10 +19,15 @@ import { QuickOpenItem, QuickOpenMode, QuickOpenGroupItem, QuickOpenItemOptions import { QuickOpenService } from './quick-open-service'; import { QuickPickService, QuickPickOptions, QuickPickItem, QuickPickSeparator, QuickPickValue } from '../../common/quick-pick-service'; import { QuickOpenHideReason } from '../../common/quick-open-service'; +import { QuickTitleBar } from './quick-title-bar'; +import { Emitter, Event } from '../../common/event'; @injectable() export class QuickPickServiceImpl implements QuickPickService { + @inject(QuickTitleBar) + protected readonly quickTitleBar: QuickTitleBar; + @inject(QuickOpenService) protected readonly quickOpenService: QuickOpenService; @@ -31,18 +36,27 @@ export class QuickPickServiceImpl implements QuickPickService { async show(elements: (string | QuickPickItem)[], options?: QuickPickOptions): Promise { return new Promise(resolve => { const items = this.toItems(elements, resolve); - if (items.length === 0) { - resolve(undefined); - return; - } if (items.length === 1) { items[0].run(QuickOpenMode.OPEN); return; } - this.quickOpenService.open({ onType: (_, acceptor) => acceptor(items) }, Object.assign({ - onClose: () => resolve(undefined), + if (options && this.quickTitleBar.shouldShowTitleBar(options.title, options.step)) { + this.quickTitleBar.attachTitleBar(this.quickOpenService.widgetNode, options.title, options.step, options.totalSteps, options.buttons); + } + const prefix = options && options.value ? options.value : ''; + this.quickOpenService.open({ + onType: (_, acceptor) => { + acceptor(items); + this.onDidChangeActiveItemsEmitter.fire(items); + } + }, Object.assign({ + onClose: () => { + resolve(undefined); + this.quickTitleBar.hide(); + }, fuzzyMatchLabel: true, - fuzzyMatchDescription: true + fuzzyMatchDescription: true, + prefix }, options)); }); } @@ -80,6 +94,7 @@ export class QuickPickServiceImpl implements QuickPickService { return false; } resolve(value); + this.onDidAcceptEmitter.fire(undefined); return true; } }; @@ -89,4 +104,10 @@ export class QuickPickServiceImpl implements QuickPickService { this.quickOpenService.hide(reason); } + private readonly onDidAcceptEmitter: Emitter = new Emitter(); + readonly onDidAccept: Event = this.onDidAcceptEmitter.event; + + private readonly onDidChangeActiveItemsEmitter: Emitter[]> = new Emitter[]>(); + readonly onDidChangeActiveItems: Event[]> = this.onDidChangeActiveItemsEmitter.event; + } diff --git a/packages/core/src/browser/quick-open/quick-title-bar.ts b/packages/core/src/browser/quick-open/quick-title-bar.ts new file mode 100644 index 0000000000000..cc86a9993f2dd --- /dev/null +++ b/packages/core/src/browser/quick-open/quick-title-bar.ts @@ -0,0 +1,259 @@ +/******************************************************************************** + * Copyright (C) 2019 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 { Emitter } from '../../common/event'; +import { DisposableCollection } from '../../common/disposable'; +import { injectable } from 'inversify'; + +export enum QuickInputTitleButtonSide { + LEFT = 0, + RIGHT = 1 +} + +export interface QuickInputTitleButton { + icon: string; // a background image coming from a url + iconClass?: string; // a class such as one coming from font awesome + tooltip?: string | undefined; + side: QuickInputTitleButtonSide +} + +@injectable() +export class QuickTitleBar { + + private readonly onDidTriggerButtonEmitter: Emitter; + private _isAttached: boolean; + + private titleElement: HTMLElement; + private titleBarContainer: HTMLElement; + private attachedNode: HTMLElement | undefined; + + private _title: string | undefined; + private _step: number | undefined; + private _totalSteps: number | undefined; + private _buttons: ReadonlyArray; + + private tabIndex = 2; // Keep track of the tabIndex for the buttons + + private disposableCollection: DisposableCollection; + constructor() { + this.titleElement = document.createElement('h3'); + this.titleElement.style.textAlign = 'center'; + this.titleElement.style.margin = '0'; + + this.disposableCollection = new DisposableCollection(); + this.disposableCollection.push(this.onDidTriggerButtonEmitter = new Emitter()); + } + + get onDidTriggerButton() { + return this.onDidTriggerButtonEmitter.event; + } + + get isAttached(): boolean { + return this._isAttached; + } + + set isAttached(isAttached: boolean) { + this._isAttached = isAttached; + } + + set title(title: string | undefined) { + this._title = title; + this.updateInnerTitleText(); + } + + get title(): string | undefined { + return this._title; + } + + set step(step: number | undefined) { + this._step = step; + this.updateInnerTitleText(); + } + + get step(): number | undefined { + return this._step; + } + + set totalSteps(totalSteps: number | undefined) { + this._totalSteps = totalSteps; + this.updateInnerTitleText(); + } + + get totalSteps(): number | undefined { + return this._totalSteps; + } + + set buttons(buttons: ReadonlyArray | undefined) { + if (buttons === undefined) { + this._buttons = []; + return; + } + + this._buttons = buttons; + } + + get buttons() { + return this._buttons; + } + + private updateInnerTitleText(): void { + let innerTitle = ''; + + if (this.title) { + innerTitle = this.title + ' '; + } + + if (this.step && this.totalSteps) { + innerTitle += `(${this.step} / ${this.totalSteps})`; + } else if (this.step) { + innerTitle += this.step; + } + + this.titleElement.innerText = innerTitle; + } + + // Left buttons are for the buttons dervied from QuickInputButtons + private getLeftButtons() { + if (this._buttons === undefined || this._buttons.length === 0) { + return []; + } + return this._buttons.filter(btn => btn.side === QuickInputTitleButtonSide.LEFT); + } + + private getRightButtons() { + if (this._buttons === undefined || this._buttons.length === 0) { + return []; + } + return this._buttons.filter(btn => btn.side === QuickInputTitleButtonSide.RIGHT); + } + + private createButtonElement(buttons: ReadonlyArray) { + const buttonDiv = document.createElement('div'); + buttonDiv.style.display = 'inline-flex'; + for (const btn of buttons) { + const aElement = document.createElement('a'); + aElement.style.width = '16px'; + aElement.style.height = '16px'; + aElement.tabIndex = 0; + if (btn.iconClass) { + aElement.classList.add(...btn.iconClass.split(' ')); + } + + if (btn.icon !== '') { + aElement.style.backgroundImage = `url(\'${btn.icon}\')`; + } + + aElement.classList.add('icon'); + aElement.style.display = 'flex'; + aElement.style.justifyContent = 'center'; + aElement.style.alignItems = 'center'; + aElement.style.cursor = 'pointer'; + aElement.tabIndex = this.tabIndex; + aElement.title = btn.tooltip ? btn.tooltip : ''; + aElement.onclick = () => { + this.onDidTriggerButtonEmitter.fire(btn); + }; + aElement.onkeyup = event => { + if (event.code === 'Enter') { + aElement.click(); + } + }; + buttonDiv.appendChild(aElement); + this.tabIndex += 1; + } + return buttonDiv; + } + + private createTitleBarDiv() { + const div = document.createElement('div'); + div.style.display = 'flex'; + div.style.flexDirection = 'row'; + div.style.fontSize = '13px'; + div.style.padding = '0px 1px'; + div.style.justifyContent = 'flex-start'; + div.style.alignItems = 'center'; + div.style.background = 'var(--theia-layout-color4)'; + div.onclick = event => { + event.stopPropagation(); + event.preventDefault(); + }; + return div; + } + + private createLeftButtonDiv() { + const leftButtonDiv = document.createElement('div'); // Holds all the buttons that get added to the left + leftButtonDiv.style.flex = '1'; + leftButtonDiv.style.textAlign = 'left'; + + leftButtonDiv.appendChild(this.createButtonElement(this.getLeftButtons())); + return leftButtonDiv; + } + + private createRightButtonDiv() { + const rightButtonDiv = document.createElement('div'); + rightButtonDiv.style.flex = '1'; + rightButtonDiv.style.textAlign = 'right'; + + rightButtonDiv.appendChild(this.createButtonElement(this.getRightButtons())); + return rightButtonDiv; + } + + // tslint:disable-next-line:max-line-length + public attachTitleBar(widgetNode: HTMLElement, title: string | undefined, step: number | undefined, totalSteps: number | undefined, buttons: ReadonlyArray | undefined) { + const div = this.createTitleBarDiv(); + + this.updateInnerTitleText(); + + this.title = title; + this.step = step; + this.totalSteps = totalSteps; + this.buttons = buttons; + + div.appendChild(this.createLeftButtonDiv()); + div.appendChild(this.titleElement); + div.appendChild(this.createRightButtonDiv()); + + if (widgetNode.contains(this.titleBarContainer)) { + widgetNode.removeChild(this.titleBarContainer); + } + widgetNode.prepend(div); + + this.titleBarContainer = div; + this.attachedNode = widgetNode; + this.isAttached = true; + } + + hide() { + this.title = undefined; + this.buttons = undefined; + this.step = undefined; + this.totalSteps = undefined; + this.isAttached = false; + if (this.attachedNode && this.attachedNode.contains(this.titleBarContainer)) { + this.attachedNode.removeChild(this.titleBarContainer); + } + this.attachedNode = undefined; + } + + shouldShowTitleBar(title: string | undefined, step: number | undefined): boolean { + return ((title !== undefined) || (step !== undefined)); + } + + dispose() { + this.disposableCollection.dispose(); + } + +} diff --git a/packages/core/src/common/quick-open-service.ts b/packages/core/src/common/quick-open-service.ts index 0ded30f1e0ad2..3e7049479ca3a 100644 --- a/packages/core/src/common/quick-open-service.ts +++ b/packages/core/src/common/quick-open-service.ts @@ -29,9 +29,12 @@ export namespace QuickOpenOptions { enableSeparateSubstringMatching?: boolean } export interface Resolved { + readonly enabled: boolean; + readonly prefix: string; readonly placeholder: string; readonly ignoreFocusOut: boolean; + readonly valueSelection: Readonly<[number, number]>; readonly fuzzyMatchLabel: boolean | FuzzyMatchOptions; readonly fuzzyMatchDetail: boolean | FuzzyMatchOptions; @@ -57,9 +60,12 @@ export namespace QuickOpenOptions { onClose(canceled: boolean): void; } export const defaultOptions: Resolved = Object.freeze({ + enabled: true, + prefix: '', placeholder: '', ignoreFocusOut: false, + valueSelection: [-1, -1] as Readonly<[number, number]>, fuzzyMatchLabel: false, fuzzyMatchDetail: false, diff --git a/packages/core/src/common/quick-pick-service.ts b/packages/core/src/common/quick-pick-service.ts index 6ea15b05b1df5..7a9a3aa028c79 100644 --- a/packages/core/src/common/quick-pick-service.ts +++ b/packages/core/src/common/quick-pick-service.ts @@ -15,6 +15,10 @@ ********************************************************************************/ import { QuickOpenHideReason } from './quick-open-service'; +import { QuickInputTitleButton } from '../browser/quick-open/quick-title-bar'; +import { Event } from '../common/event'; +import { QuickOpenItem, QuickOpenItemOptions } from '../browser/quick-open/quick-open-model'; + export type QuickPickItem = QuickPickValue | QuickPickSeparator; export interface QuickPickSeparator { @@ -45,6 +49,36 @@ export interface QuickPickOptions { * default: true */ fuzzyMatchDescription?: boolean + + /** + * Current step count + */ + step?: number | undefined + + /** + * The title of the input + */ + title?: string | undefined + + /** + * Total number of steps + */ + totalSteps?: number | undefined + + /** + * Buttons that are displayed on the title panel + */ + buttons?: ReadonlyArray + + /** + * Set to `true` to keep the input box open when focus moves to another part of the editor or to another window. + */ + ignoreFocusOut?: boolean + + /** + * The prefill value. + */ + value?: string; } export const quickPickServicePath = '/services/quickPick'; @@ -57,4 +91,6 @@ export interface QuickPickService { hide(reason?: QuickOpenHideReason): void + readonly onDidAccept: Event; + readonly onDidChangeActiveItems: Event[]>; } diff --git a/packages/monaco/src/browser/monaco-quick-open-service.ts b/packages/monaco/src/browser/monaco-quick-open-service.ts index b7dbecd090359..b02a6e5783f9d 100644 --- a/packages/monaco/src/browser/monaco-quick-open-service.ts +++ b/packages/monaco/src/browser/monaco-quick-open-service.ts @@ -27,6 +27,8 @@ import { MonacoContextKeyService } from './monaco-context-key-service'; import { QuickOpenHideReason } from '@theia/core/lib/common/quick-open-service'; export interface MonacoQuickOpenControllerOpts extends monaco.quickOpen.IQuickOpenControllerOpts { + valueSelection?: Readonly<[number, number]>; + enabled?: boolean; readonly prefix?: string; readonly password?: boolean; readonly ignoreFocusOut?: boolean; @@ -41,6 +43,7 @@ export class MonacoQuickOpenService extends QuickOpenService { protected _widget: monaco.quickOpen.QuickOpenWidget | undefined; protected opts: MonacoQuickOpenControllerOpts | undefined; protected previousActiveElement: Element | undefined; + protected _widgetNode: HTMLElement; @inject(MonacoContextKeyService) protected readonly contextKeyService: MonacoContextKeyService; @@ -60,6 +63,7 @@ export class MonacoQuickOpenService extends QuickOpenService { container.style.position = 'absolute'; container.style.top = '0px'; container.style.right = '50%'; + container.style.zIndex = '1000000'; overlayWidgets.appendChild(container); } @@ -101,6 +105,13 @@ export class MonacoQuickOpenService extends QuickOpenService { this.clearInputDecoration(); } + refresh(): void { + const inputBox = this.widget.inputBox; + if (inputBox) { + this.onType(inputBox.inputElement.value); + } + } + internalOpen(opts: MonacoQuickOpenControllerOpts): void { this.opts = opts; const activeContext = window.document.activeElement || undefined; @@ -108,11 +119,54 @@ export class MonacoQuickOpenService extends QuickOpenService { this.previousActiveElement = activeContext; this.contextKeyService.activeContext = activeContext instanceof HTMLElement ? activeContext : undefined; } + this.hideDecoration(); this.widget.show(this.opts.prefix || ''); this.setPlaceHolder(opts.inputAriaLabel); this.setPassword(opts.password ? true : false); + this.setEnabled(opts.enabled); + this.setValueSelected(opts.inputAriaLabel, opts.valueSelection); this.inQuickOpenKey.set(true); + + const widget = this.widget; + if (widget.inputBox) { + widget.inputBox.inputElement.tabIndex = 1; + } + } + + setValueSelected(value: string | undefined, selectLocation: Readonly<[number, number]> | undefined) { + if (!value) { + return; + } + + const widget = this.widget; + if (widget.inputBox) { + + if (!selectLocation) { + widget.inputBox.inputElement.setSelectionRange(0, value.length); + return; + } + + if (selectLocation[0] === selectLocation[1]) { + widget.inputBox.inputElement.setSelectionRange(selectLocation[0], selectLocation[0]); + return; + } + + widget.inputBox.inputElement.setSelectionRange(selectLocation[0], selectLocation[1]); + } + } + + setEnabled(isEnabled: boolean | undefined) { + const widget = this.widget; + if (widget.inputBox) { + widget.inputBox.inputElement.readOnly = (isEnabled !== undefined) ? !isEnabled : false; + } + } + + setValue(value: string | undefined) { + if (this.widget && this.widget.inputBox) { + this.widget.inputBox.inputElement.value = (value !== undefined) ? value : ''; + } } setPlaceHolder(placeHolder: string): void { @@ -164,13 +218,27 @@ export class MonacoQuickOpenService extends QuickOpenService { this.onClose(true); }, onType: lookFor => this.onType(lookFor || ''), - onFocusLost: () => (this.opts && this.opts.ignoreFocusOut !== undefined) ? this.opts.ignoreFocusOut : false + onFocusLost: () => { + if (this.opts && this.opts.ignoreFocusOut !== undefined) { + if (this.opts.ignoreFocusOut === false) { + this.onClose(true); + } + return this.opts.ignoreFocusOut; + } else { + return false; + } + } }, {}); this.attachQuickOpenStyler(); - this._widget.create(); + const newWidget = this._widget.create(); + this._widgetNode = newWidget; return this._widget; } + get widgetNode(): HTMLElement { + return this._widgetNode; + } + protected attachQuickOpenStyler(): void { if (!this._widget) { return; @@ -221,6 +289,10 @@ export class MonacoQuickOpenControllerOptsImpl implements MonacoQuickOpenControl this.password = this.options.password; } + get enabled(): boolean { + return this.options.enabled; + } + get prefix(): string { return this.options.prefix; } @@ -233,6 +305,10 @@ export class MonacoQuickOpenControllerOptsImpl implements MonacoQuickOpenControl return this.options.placeholder || ''; } + get valueSelection(): Readonly<[number, number]> { + return this.options.valueSelection || [-1, -1]; + } + onClose(cancelled: boolean): void { this.options.onClose(cancelled); } diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 1902a295ee2a1..541bdd32c2c58 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -26,7 +26,8 @@ import { EndOfLine, OverviewRulerLane, IndentAction, - FileOperationOptions + FileOperationOptions, + QuickInputButton } from '../plugin/types-impl'; import { UriComponents } from './uri-components'; import { ConfigurationTarget } from '../plugin/types-impl'; @@ -71,6 +72,9 @@ import { DebugProtocol } from 'vscode-debugprotocol'; import { SymbolInformation } from 'vscode-languageserver-types'; import { ScmCommand } from '@theia/scm/lib/browser/scm-provider'; import { ArgumentProcessor } from '../plugin/command-registry'; +import { MaybePromise } from '@theia/core/lib/common/types'; +import { QuickInputTitleButton } from '@theia/core/lib/browser/quick-open/quick-title-bar'; +import { QuickOpenItem, QuickOpenItemOptions } from '@theia/core/lib/browser/quick-open/quick-open-model'; export interface PluginInitData { plugins: PluginMetadata[]; @@ -296,6 +300,13 @@ export interface StatusBarMessageRegistryMain { export interface QuickOpenExt { $onItemSelected(handle: number): void; $validateInput(input: string): PromiseLike | undefined; + + $acceptOnDidAccept(quickInputNumber: number): Promise; + $acceptDidChangeValue(quickInputNumber: number, changedValue: string): Promise; + $acceptOnDidHide(quickInputNumber: number): Promise; + $acceptOnDidTriggerButton(quickInputNumber: number, btn: QuickInputTitleButton): Promise; + $acceptDidChangeActive(quickInputNumber: number, changedItems: QuickOpenItem[]): Promise; + $acceptDidChangeSelection(quickInputNumber: number, selection: string): Promise; } /** @@ -382,12 +393,52 @@ export interface WorkspaceFolderPickOptionsMain { ignoreFocusOut?: boolean; } +export interface QuickInputTitleButtonHandle extends QuickInputTitleButton { + index: number; // index of where they are in buttons array if QuickInputButton or -1 if QuickInputButtons.Back +} + +export interface ITransferQuickInput { + quickInputIndex: number; + title: string | undefined; + step: number | undefined; + totalSteps: number | undefined; + enabled: boolean; + busy: boolean; + ignoreFocusOut: boolean; +} + +export interface ITransferInputBox extends ITransferQuickInput { + value: string; + placeholder: string | undefined; + password: boolean; + buttons: ReadonlyArray; + prompt: string | undefined; + validationMessage: string | undefined; + validateInput(value: string): MaybePromise; +} + +export interface ITransferQuickPick extends ITransferQuickInput { + value: string; + placeholder: string | undefined; + buttons: ReadonlyArray; + items: PickOpenItem[]; + canSelectMany: boolean; + matchOnDescription: boolean; + matchOnDetail: boolean; + activeItems: ReadonlyArray; + selectedItems: ReadonlyArray; +} + export interface QuickOpenMain { $show(options: PickOptions): Promise; $setItems(items: PickOpenItem[]): Promise; $setError(error: Error): Promise; $input(options: theia.InputBoxOptions, validateInput: boolean): Promise; $hide(): void; + $showInputBox(inputBox: ITransferInputBox, validateInput: boolean): void; + $showCustomQuickPick(inputBox: ITransferQuickPick): void; + $setQuickInputChanged(changed: object): void; + $refreshQuickInput(): void; } export interface WorkspaceMain { 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 6d43d983b0a42..5f77fab632086 100644 --- a/packages/plugin-ext/src/main/browser/quick-open-main.ts +++ b/packages/plugin-ext/src/main/browser/quick-open-main.ts @@ -14,29 +14,42 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { InputBoxOptions } from '@theia/plugin'; +import { InputBoxOptions, QuickPickItem as QuickPickItemExt } from '@theia/plugin'; import { interfaces } from 'inversify'; import { QuickOpenModel, QuickOpenItem, QuickOpenMode } from '@theia/core/lib/browser/quick-open/quick-open-model'; import { RPCProtocol } from '../../common/rpc-protocol'; -import { QuickOpenExt, QuickOpenMain, MAIN_RPC_CONTEXT, PickOptions, PickOpenItem } from '../../common/plugin-api-rpc'; +import { QuickOpenExt, QuickOpenMain, MAIN_RPC_CONTEXT, PickOptions, PickOpenItem, ITransferInputBox, QuickInputTitleButtonHandle, ITransferQuickPick } from '../../common/plugin-api-rpc'; import { MonacoQuickOpenService } from '@theia/monaco/lib/browser/monaco-quick-open-service'; -import { QuickInputService } from '@theia/core/lib/browser'; +import { QuickInputService, FOLDER_ICON, FILE_ICON } from '@theia/core/lib/browser'; +import { PluginSharedStyle } from './plugin-shared-style'; +import URI from 'vscode-uri'; +import { ThemeIcon, QuickInputButton } from '../../plugin/types-impl'; +import { QuickPickService, QuickPickItem, QuickPickValue } from '@theia/core/lib/common/quick-pick-service'; +import { QuickTitleBar, QuickInputTitleButtonSide } from '@theia/core/lib/browser/quick-open/quick-title-bar'; +import { DisposableCollection } from '@theia/core/lib/common/disposable'; export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { private quickInput: QuickInputService; + private quickPick: QuickPickService; + private quickTitleBar: QuickTitleBar; private doResolve: (value?: number | number[] | PromiseLike | undefined) => void; private proxy: QuickOpenExt; private delegate: MonacoQuickOpenService; private acceptor: ((items: QuickOpenItem[]) => void) | undefined; private items: QuickOpenItem[] | undefined; + private sharedStyle: PluginSharedStyle; + private activeElement: HTMLElement | undefined; constructor(rpc: RPCProtocol, container: interfaces.Container) { this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.QUICK_OPEN_EXT); this.delegate = container.get(MonacoQuickOpenService); this.quickInput = container.get(QuickInputService); + this.quickTitleBar = container.get(QuickTitleBar); + this.quickPick = container.get(QuickPickService); + this.sharedStyle = container.get(PluginSharedStyle); } private cleanUp(): void { @@ -90,6 +103,20 @@ export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { return Promise.resolve(); } + private convertPickOpenItemToQuickOpenItem(items: PickOpenItem[]): QuickPickItem[] { + const convertedItems: QuickPickItem[] = []; + for (const i of items) { + convertedItems.push({ + label: i.label, + description: i.description, + detail: i.detail, + type: i, + value: i.label + } as QuickPickValue); + } + return convertedItems; + } + // tslint:disable-next-line:no-any $setError(error: Error): Promise { throw new Error('Method not implemented.'); @@ -103,6 +130,166 @@ export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { return this.quickInput.open(options); } + convertQuickInputButton(quickInputButton: QuickInputButton, index: number): QuickInputTitleButtonHandle { + const currentIconPath = quickInputButton.iconPath; + let newIcon = ''; + let newIconClass = ''; + if ('id' in currentIconPath || currentIconPath instanceof ThemeIcon) { + newIconClass = this.resolveIconClassFromThemeIcon(currentIconPath); + } else if (currentIconPath instanceof URI) { + newIcon = currentIconPath.toString(); + } else { + const { light, dark } = currentIconPath as { light: string | URI, dark: string | URI }; + const themedIconClasses = { + light: light.toString(), + dark: dark.toString() + }; + newIconClass = this.sharedStyle.toIconClass(themedIconClasses); + } + + const isDefaultQuickInputButton = 'id' in quickInputButton.iconPath && quickInputButton.iconPath.id === 'Back' ? true : false; + return { + icon: newIcon, + iconClass: newIconClass, + tooltip: quickInputButton.tooltip, + side: isDefaultQuickInputButton ? QuickInputTitleButtonSide.LEFT : QuickInputTitleButtonSide.RIGHT, + index: isDefaultQuickInputButton ? -1 : index + }; + } + + private resolveIconClassFromThemeIcon(themeIconID: ThemeIcon): string { + switch (themeIconID.id) { + case 'folder': { + return FOLDER_ICON; + } + case 'file': { + return FILE_ICON; + } + case 'Back': { + return 'fa fa-arrow-left'; + } + default: { + return ''; + } + } + } + + $showInputBox(inputBox: ITransferInputBox, validateInput: boolean): void { + if (validateInput) { + inputBox.validateInput = val => this.proxy.$validateInput(val); + } + + const quickInput = this.quickInput.open({ + busy: inputBox.busy, + enabled: inputBox.enabled, + ignoreFocusOut: inputBox.ignoreFocusOut, + password: inputBox.password, + step: inputBox.step, + title: inputBox.title, + totalSteps: inputBox.totalSteps, + buttons: inputBox.buttons.map((btn, i) => this.convertQuickInputButton(btn, i)), + validationMessage: inputBox.validationMessage, + placeHolder: inputBox.placeholder, + value: inputBox.value, + prompt: inputBox.prompt, + validateInput: inputBox.validateInput + }); + + const disposableListeners = new DisposableCollection(); + disposableListeners.push(this.quickInput.onDidAccept(() => this.proxy.$acceptOnDidAccept(inputBox.quickInputIndex))); + disposableListeners.push(this.quickInput.onDidChangeValue(changedText => this.proxy.$acceptDidChangeValue(inputBox.quickInputIndex, changedText))); + disposableListeners.push(this.quickTitleBar.onDidTriggerButton(button => { + this.proxy.$acceptOnDidTriggerButton(inputBox.quickInputIndex, button); + })); + quickInput.then(selection => { + if (selection) { + this.proxy.$acceptDidChangeSelection(inputBox.quickInputIndex, selection as string); + } + this.proxy.$acceptOnDidHide(inputBox.quickInputIndex); + disposableListeners.dispose(); + }); + } + + // tslint:disable-next-line:no-any + private findChangedKey(key: string, value: any) { + switch (key) { + case 'title': { + this.quickTitleBar.title = value; + break; + } + case 'step': { + this.quickTitleBar.step = value; + break; + } + case 'totalSteps': { + this.quickTitleBar.totalSteps = value; + break; + } + case 'buttons': { + this.quickTitleBar.buttons = value; + break; + } + case 'value': { + this.delegate.setValue(value); + break; + } + case 'enabled': { + this.delegate.setEnabled(value); + break; + } + case 'password': { + this.delegate.setPassword(value); + break; + } + case 'placeholder': { + this.delegate.setPlaceHolder(value); + break; + } + } + } + + // tslint:disable-next-line:no-any + $setQuickInputChanged(changed: any) { + for (const key in changed) { + if (changed.hasOwnProperty(key)) { + const value = changed[key]; + this.findChangedKey(key, value); + } + } + } + $refreshQuickInput() { + this.quickInput.refresh(); + } + + $showCustomQuickPick(options: ITransferQuickPick): void { + const items = this.convertPickOpenItemToQuickOpenItem(options.items); + const quickPick = this.quickPick.show(items, { + buttons: options.buttons.map((btn, i) => this.convertQuickInputButton(btn, i)), + placeholder: options.placeholder, + fuzzyMatchDescription: options.matchOnDescription, + fuzzyMatchLabel: true, + step: options.step, + title: options.title, + totalSteps: options.totalSteps, + ignoreFocusOut: options.ignoreFocusOut, + value: options.value + }); + + const disposableListeners = new DisposableCollection(); + disposableListeners.push(this.quickPick.onDidAccept(() => this.proxy.$acceptOnDidAccept(options.quickInputIndex))); + disposableListeners.push(this.quickPick.onDidChangeActiveItems(changedItems => this.proxy.$acceptDidChangeActive(options.quickInputIndex, changedItems))); + disposableListeners.push(this.quickTitleBar.onDidTriggerButton(button => { + this.proxy.$acceptOnDidTriggerButton(options.quickInputIndex, button); + })); + quickPick.then(selection => { + if (selection) { + this.proxy.$acceptDidChangeSelection(options.quickInputIndex, selection as string); + } + this.proxy.$acceptOnDidHide(options.quickInputIndex); + disposableListeners.dispose(); + }); + } + onType(lookFor: string, acceptor: (items: QuickOpenItem[]) => void): void { this.acceptor = acceptor; if (this.items) { diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index a6b973025f3c0..a553ae34de7c0 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -108,6 +108,7 @@ import { WebviewPanelTargetArea, FileSystemError, CommentThreadCollapsibleState, + QuickInputButtons, CommentMode } from './types-impl'; import { SymbolKind } from '../common/plugin-api-rpc-model'; @@ -300,7 +301,7 @@ export function createAPIFactory( } }, createQuickPick(): QuickPick { - return quickOpenExt.createQuickPick(); + return quickOpenExt.createQuickPick(plugin); }, showWorkspaceFolderPick(options?: theia.WorkspaceFolderPickOptions): PromiseLike { return workspaceExt.pickWorkspaceFolder(options); @@ -379,6 +380,9 @@ export function createAPIFactory( }, registerUriHandler(handler: theia.UriHandler): theia.Disposable { return new Disposable(() => { }); + }, + createInputBox(): theia.InputBox { + return quickOpenExt.createInputBox(plugin); } }; @@ -826,6 +830,7 @@ export function createAPIFactory( WebviewPanelTargetArea, FileSystemError, CommentThreadCollapsibleState, + QuickInputButtons, CommentMode }; }; diff --git a/packages/plugin-ext/src/plugin/quick-open.ts b/packages/plugin-ext/src/plugin/quick-open.ts index f3bda3fd4f493..c0c771815e4fa 100644 --- a/packages/plugin-ext/src/plugin/quick-open.ts +++ b/packages/plugin-ext/src/plugin/quick-open.ts @@ -13,15 +13,20 @@ * * 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 '../common/plugin-api-rpc'; -import { QuickPickOptions, QuickPickItem, InputBoxOptions } from '@theia/plugin'; +import { QuickOpenExt, PLUGIN_RPC_CONTEXT as Ext, QuickOpenMain, ITransferInputBox, Plugin } from '../common/plugin-api-rpc'; +import { QuickPickOptions, QuickPickItem, InputBoxOptions, InputBox, QuickPick, QuickInput } from '@theia/plugin'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; import { RPCProtocol } from '../common/rpc-protocol'; import { anyPromise } from '../common/async-util'; import { hookCancellationToken } from '../common/async-util'; import { Emitter, Event } from '@theia/core/lib/common/event'; -import { QuickPick, QuickInputButton } from '@theia/plugin'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { QuickInputButtons, QuickInputButton, ThemeIcon } from './types-impl'; +import { QuickInputTitleButtonHandle, ITransferQuickPick, PluginPackage } from '../common'; +import URI from 'vscode-uri'; +import * as path from 'path'; +import { quickPickItemToPickOpenItem } from './type-converters'; +import { QuickOpenItem, QuickOpenItemOptions } from '@theia/core/lib/browser/quick-open/quick-open-model'; export type Item = string | QuickPickItem; @@ -30,6 +35,9 @@ export class QuickOpenExtImpl implements QuickOpenExt { private selectItemHandler: undefined | ((handle: number) => void); private validateInputHandler: undefined | ((input: string) => string | PromiseLike | undefined); + private createdQuickInputs = new Map(); // Each quickinput will have a number so that we know where to fire events + private currentQuickInputs = 0; + constructor(rpc: RPCProtocol) { this.proxy = rpc.getProxy(Ext.QUICK_OPEN_MAIN); } @@ -67,26 +75,8 @@ export class QuickOpenExtImpl implements QuickOpenExt { return undefined; } return itemPromise.then(items => { - const pickItems: PickOpenItem[] = []; - for (let handle = 0; handle < items.length; handle++) { - const item = items[handle]; - let label: string; - let description: string | undefined; - let detail: string | undefined; - let picked: boolean | undefined; - if (typeof item === 'string') { - label = item; - } else { - ({ label, description, detail, picked } = item); - } - pickItems.push({ - label, - description, - handle, - detail, - picked - }); - } + + const pickItems = quickPickItemToPickOpenItem(items); if (options && typeof options.onDidSelectItem === 'function') { this.selectItemHandler = handle => { @@ -112,8 +102,15 @@ export class QuickOpenExtImpl implements QuickOpenExt { return hookCancellationToken(token, promise); } - createQuickPick(): QuickPick { - return new QuickPickExt(this); + showCustomQuickPick(options: ITransferQuickPick): void { + this.proxy.$showCustomQuickPick(options); + } + + createQuickPick(plugin: Plugin): QuickPick { + const newQuickInput = new QuickPickExt(this, this.proxy, plugin, this.currentQuickInputs); + this.createdQuickInputs.set(this.currentQuickInputs, newQuickInput); + this.currentQuickInputs += 1; + return newQuickInput; } showInput(options?: InputBoxOptions, token: CancellationToken = CancellationToken.None): PromiseLike { @@ -132,62 +129,407 @@ export class QuickOpenExtImpl implements QuickOpenExt { hide(): void { this.proxy.$hide(); } + showInputBox(options: ITransferInputBox): void { + this.validateInputHandler = options && options.validateInput; + this.proxy.$showInputBox(options, typeof this.validateInputHandler === 'function'); + } + + createInputBox(plugin: Plugin): InputBox { + const newQuickInput = new InputBoxExt(this, this.proxy, plugin, this.currentQuickInputs); + this.createdQuickInputs.set(this.currentQuickInputs, newQuickInput); + this.currentQuickInputs += 1; + return newQuickInput; + } + + async $acceptOnDidAccept(createdQuickInputNumber: number): Promise { + const currentQuickInput = this.createdQuickInputs.get(createdQuickInputNumber); + if (currentQuickInput) { + currentQuickInput._fireAccept(); + } + } + + async $acceptDidChangeValue(createdQuickInputNumber: number, changedValue: string): Promise { + const currentQuickInput = this.createdQuickInputs.get(createdQuickInputNumber); + if (currentQuickInput) { + currentQuickInput._fireChangedValue(changedValue); + } + } + + async $acceptOnDidHide(createdQuickInputNumber: number): Promise { + const currentQuickInput = this.createdQuickInputs.get(createdQuickInputNumber); + if (currentQuickInput) { + currentQuickInput._fireHide(); + } + } + + async $acceptOnDidTriggerButton(createdQuickInputNumber: number, btn: QuickInputTitleButtonHandle): Promise { + const thisQuickInput = this.createdQuickInputs.get(createdQuickInputNumber); + if (thisQuickInput) { + if (btn.index === -1) { + thisQuickInput._fireButtonTrigger(QuickInputButtons.Back); + } else if (thisQuickInput && (thisQuickInput instanceof InputBoxExt || thisQuickInput instanceof QuickPickExt)) { + const btnFromIndex = thisQuickInput.buttons[btn.index]; + thisQuickInput._fireButtonTrigger(btnFromIndex as QuickInputButton); + } + } + } + + async $acceptDidChangeActive(createdQuickInputNumber: number, changedItems: QuickOpenItem[]): Promise { + const thisQuickInput = this.createdQuickInputs.get(createdQuickInputNumber); + if (thisQuickInput && thisQuickInput instanceof QuickPickExt) { + thisQuickInput._fireChangedActiveItem(changedItems); + } + } + + async $acceptDidChangeSelection(createdQuickInputNumber: number, selection: string): Promise { + const currentQuickInput = this.createdQuickInputs.get(createdQuickInputNumber); + if (currentQuickInput && currentQuickInput instanceof QuickPickExt) { + // Find the item that matches that particular label + const findMatchingItem = currentQuickInput.items.filter(x => x.label === selection); + currentQuickInput._fireChangedSelection(findMatchingItem); + } + } +} + +export class QuickInputExt implements QuickInput { + + private _busy: boolean; + private _enabled: boolean; + private _ignoreFocusOut: boolean; + private _step: number | undefined; + private _title: string | undefined; + private _totalSteps: number | undefined; + private _value: string; + + protected visible: boolean; + + protected disposableCollection: DisposableCollection; + + private onDidAcceptEmitter: Emitter; + private onDidChangeValueEmitter: Emitter; + private onDidHideEmitter: Emitter; + private onDidTriggerButtonEmitter: Emitter; + + constructor(readonly quickOpen: QuickOpenExtImpl, readonly quickOpenMain: QuickOpenMain, readonly plugin: Plugin) { + this.title = undefined; + this.step = undefined; + this.totalSteps = undefined; + this.enabled = true; + this.busy = false; + this.ignoreFocusOut = false; + this.value = ''; + + this.visible = false; + + this.disposableCollection = new DisposableCollection(); + this.disposableCollection.push(this.onDidAcceptEmitter = new Emitter()); + this.disposableCollection.push(this.onDidChangeValueEmitter = new Emitter()); + this.disposableCollection.push(this.onDidHideEmitter = new Emitter()); + this.disposableCollection.push(this.onDidTriggerButtonEmitter = new Emitter()); + } + + get title(): string | undefined { + return this._title; + } + + set title(title: string | undefined) { + this._title = title; + this.update({ title }); + } + + get step(): number | undefined { + return this._step; + } + + set step(step: number | undefined) { + this._step = step; + this.update({ step }); + } + + get totalSteps(): number | undefined { + return this._totalSteps; + } + + set totalSteps(totalSteps: number | undefined) { + this._totalSteps = totalSteps; + this.update({ totalSteps }); + } + + get enabled(): boolean { + return this._enabled; + } + + set enabled(enabled: boolean) { + this._enabled = enabled; + this.update({ enabled }); + } + + get busy(): boolean { + return this._busy; + } + + set busy(busy: boolean) { + this._busy = busy; + this.update({ busy }); + } + + get ignoreFocusOut(): boolean { + return this._ignoreFocusOut; + } + + set ignoreFocusOut(ignoreFocusOut: boolean) { + this._ignoreFocusOut = ignoreFocusOut; + this.update({ ignoreFocusOut }); + } + + get value(): string { + return this._value; + } + + set value(value: string) { + this._value = value; + this.update({ value }); + } + + show() { + throw new Error('Method implementation must be provided by extenders'); + } + + dispose() { + this.disposableCollection.dispose(); + } + + protected update(changed: object) { + /** + * The args are just going to be set when we call show for the first time. + * We return early when its invisible to avoid race condition + */ + if (!this.visible || changed === undefined) { + return; + } + + this.quickOpenMain.$setQuickInputChanged(changed); + } + + hide(): void { + this.quickOpen.hide(); + this.dispose(); + } + + protected convertURL(iconPath: URI | { light: string | URI; dark: string | URI } | ThemeIcon): URI | { light: string | URI; dark: string | URI } | ThemeIcon { + const toUrl = (arg: string | URI) => { + arg = arg instanceof URI && arg.scheme === 'file' ? arg.fsPath : arg; + if (typeof arg !== 'string') { + return arg.toString(true); + } + const { packagePath } = this.plugin.rawModel; + const absolutePath = path.isAbsolute(arg) ? arg : path.join(packagePath, arg); + const normalizedPath = path.normalize(absolutePath); + const relativePath = path.relative(packagePath, normalizedPath); + return PluginPackage.toPluginUrl(this.plugin.rawModel, relativePath); + }; + if ('id' in iconPath || iconPath instanceof ThemeIcon) { + return iconPath; + } else if (typeof iconPath === 'string' || iconPath instanceof URI) { + return URI.parse(toUrl(iconPath)); + } else { + const { light, dark } = iconPath as { light: string | URI, dark: string | URI }; + return { + light: toUrl(light), + dark: toUrl(dark) + }; + } + } + + _fireAccept() { + this.onDidAcceptEmitter.fire(undefined); + } + + _fireChangedValue(changedValue: string) { + this.onDidChangeValueEmitter.fire(changedValue); + } + + _fireHide() { + this.onDidHideEmitter.fire(undefined); + } + + _fireButtonTrigger(btn: QuickInputButton) { + this.onDidTriggerButtonEmitter.fire(btn); + } + get onDidHide(): Event { + return this.onDidHideEmitter.event; + } + + get onDidAccept(): Event { + return this.onDidAcceptEmitter.event; + } + + get onDidChangeValue(): Event { + return this.onDidChangeValueEmitter.event; + } + + get onDidTriggerButton(): Event { + return this.onDidTriggerButtonEmitter.event; + } } + +/** + * Base implementation of {@link InputBox} that uses {@link QuickOpenExt}. + * Missing functionality is going to be implemented in the scope of https://github.com/theia-ide/theia/issues/5109 + */ +export class InputBoxExt extends QuickInputExt implements InputBox { + + /** + * Input Box API Start + */ + private _placeholder: string | undefined; + private _password: boolean; + + private _buttons: ReadonlyArray; + private _prompt: string | undefined; + private _validationMessage: string | undefined; + /** + * Input Box API End + */ + + constructor(readonly quickOpen: QuickOpenExtImpl, + readonly quickOpenMain: QuickOpenMain, + readonly plugin: Plugin, + readonly quickInputIndex: number) { + + super(quickOpen, quickOpenMain, plugin); + + this.buttons = []; + this.password = false; + this.value = ''; + } + + get buttons(): ReadonlyArray { + return this._buttons; + } + + set buttons(buttons: ReadonlyArray) { + this._buttons = buttons; + this.update({ buttons }); + } + + get password(): boolean { + return this._password; + } + + set password(password: boolean) { + this._password = password; + this.update({ password }); + } + + get placeholder(): string | undefined { + return this._placeholder; + } + + set placeholder(placeholder: string | undefined) { + this._placeholder = placeholder; + this.update({ placeholder }); + } + + get prompt(): string | undefined { + return this._prompt; + } + + set prompt(prompt: string | undefined) { + this._prompt = prompt; + this.update({ prompt }); + } + + get validationMessage(): string | undefined { + return this._validationMessage; + } + + set validationMessage(validationMessage: string | undefined) { + if (this._validationMessage !== validationMessage) { + this._validationMessage = validationMessage; + this.update({ validationMessage }); + this.quickOpenMain.$refreshQuickInput(); + } + } + + show(): void { + this.visible = true; + const update = (value: string) => { + this.value = value; + // this.onDidChangeValueEmitter.fire(value); + if (this.validationMessage && this.validationMessage.length > 0) { + return this.validationMessage; + } + }; + this.quickOpen.showInputBox({ + quickInputIndex: this.quickInputIndex, + busy: this.busy, + buttons: this.buttons.map(btn => ({ + 'iconPath': this.convertURL(btn.iconPath), + 'tooltip': btn.tooltip + })), + enabled: this.enabled, + ignoreFocusOut: this.ignoreFocusOut, + password: this.password, + placeholder: this.placeholder, + prompt: this.prompt, + step: this.step, + title: this.title, + totalSteps: this.totalSteps, + validationMessage: this.validationMessage, + value: this.value, + validateInput(value: string): string | undefined { + if (value.length > 0) { + return update(value); + } + } + }); + } +} + /** * Base implementation of {@link QuickPick} that uses {@link QuickOpenExt}. * Missing functionality is going to be implemented in the scope of https://github.com/theia-ide/theia/issues/5059 */ -export class QuickPickExt implements QuickPick { +export class QuickPickExt extends QuickInputExt implements QuickPick { - busy: boolean; buttons: ReadonlyArray; canSelectMany: boolean; - enabled: boolean; - ignoreFocusOut: boolean; matchOnDescription: boolean; matchOnDetail: boolean; selectedItems: ReadonlyArray; - step: number | undefined; - title: string | undefined; - totalSteps: number | undefined; value: string; private _items: T[]; private _activeItems: T[]; private _placeholder: string | undefined; - private disposableCollection: DisposableCollection; - private readonly onDidHideEmitter: Emitter; - private readonly onDidAcceptEmitter: Emitter; private readonly onDidChangeActiveEmitter: Emitter; private readonly onDidChangeSelectionEmitter: Emitter; - private readonly onDidChangeValueEmitter: Emitter; - private readonly onDidTriggerButtonEmitter: Emitter; - constructor(readonly quickOpen: QuickOpenExtImpl) { + constructor(readonly quickOpen: QuickOpenExtImpl, + readonly quickOpenMain: QuickOpenMain, + readonly plugin: Plugin, + readonly quickInputIndex: number) { + + super(quickOpen, quickOpenMain, plugin); this._items = []; this._activeItems = []; this._placeholder = ''; this.buttons = []; - this.step = 0; - this.title = ''; - this.totalSteps = 0; this.value = ''; - this.disposableCollection = new DisposableCollection(); - this.disposableCollection.push(this.onDidHideEmitter = new Emitter()); - this.disposableCollection.push(this.onDidAcceptEmitter = new Emitter()); + this.disposableCollection.push(this.onDidChangeActiveEmitter = new Emitter()); this.disposableCollection.push(this.onDidChangeSelectionEmitter = new Emitter()); - this.disposableCollection.push(this.onDidChangeValueEmitter = new Emitter()); - this.disposableCollection.push(this.onDidTriggerButtonEmitter = new Emitter()); } get items(): T[] { return this._items; } - set items(activeItems: T[]) { - this._items = activeItems; + set items(items: T[]) { + this._items = items; + this.update({ items }); } get activeItems(): T[] { @@ -196,65 +538,55 @@ export class QuickPickExt implements QuickPick { set activeItems(activeItems: T[]) { this._activeItems = activeItems; - } - - get onDidAccept(): Event { - return this.onDidAcceptEmitter.event; + this.update({ activeItems }); } get placeholder(): string | undefined { return this._placeholder; } + set placeholder(placeholder: string | undefined) { this._placeholder = placeholder; + this.update({ placeholder }); } - get onDidChangeActive(): Event { - return this.onDidChangeActiveEmitter.event; - } - - get onDidChangeSelection(): Event { - return this.onDidChangeSelectionEmitter.event; + _fireChangedSelection(selections: T[]) { + this.onDidChangeSelectionEmitter.fire(selections); } - get onDidChangeValue(): Event { - return this.onDidChangeValueEmitter.event; + _fireChangedActiveItem(changedItems: QuickOpenItem[]) { + this.onDidChangeActiveEmitter.fire(changedItems as unknown as T[]); } - get onDidTriggerButton(): Event { - return this.onDidTriggerButtonEmitter.event; - } - - get onDidHide(): Event { - return this.onDidHideEmitter.event; - } - - dispose(): void { - this.disposableCollection.dispose(); + get onDidChangeActive(): Event { + return this.onDidChangeActiveEmitter.event; } - hide(): void { - this.quickOpen.hide(); - this.dispose(); + get onDidChangeSelection(): Event { + return this.onDidChangeSelectionEmitter.event; } show(): void { - const hide = () => { - this.onDidHideEmitter.fire(undefined); - }; - const selectItem = (item: T) => { - this.activeItems = [item]; - this.onDidAcceptEmitter.fire(undefined); - this.onDidChangeSelectionEmitter.fire([item]); - }; - this.quickOpen.showQuickPick(this.items.map(item => item as T), { - // tslint:disable-next-line:no-any - onDidSelectItem(item: T | string): any { - if (typeof item !== 'string') { - selectItem(item); - } - hide(); - }, placeHolder: this.placeholder + this.quickOpen.showCustomQuickPick({ + quickInputIndex: this.quickInputIndex, + title: this.title, + step: this.step, + totalSteps: this.totalSteps, + enabled: this.enabled, + busy: this.busy, + ignoreFocusOut: this.ignoreFocusOut, + value: this.value, + placeholder: this.placeholder, + buttons: this.buttons.map(btn => ({ + 'iconPath': this.convertURL(btn.iconPath), + 'tooltip': btn.tooltip + })), + items: quickPickItemToPickOpenItem(this.items), + canSelectMany: this.canSelectMany, + matchOnDescription: this.matchOnDescription, + matchOnDetail: this.matchOnDetail, + activeItems: this.activeItems, + selectedItems: this.selectedItems }); } diff --git a/packages/plugin-ext/src/plugin/type-converters.ts b/packages/plugin-ext/src/plugin/type-converters.ts index 4c5f11ee83cb3..1aaf4c9a8c97d 100644 --- a/packages/plugin-ext/src/plugin/type-converters.ts +++ b/packages/plugin-ext/src/plugin/type-converters.ts @@ -24,6 +24,7 @@ import { ResourceFileEditDto, TaskDto, ProcessTaskDto +, PickOpenItem } from '../common/plugin-api-rpc'; import * as model from '../common/plugin-api-rpc-model'; import * as theia from '@theia/plugin'; @@ -35,6 +36,7 @@ import URI from 'vscode-uri'; const SIDE_GROUP = -2; const ACTIVE_GROUP = -1; import { SymbolInformation, Range as R, Position as P, SymbolKind as S, Location as L } from 'vscode-languageserver-types'; +import { Item } from './quick-open'; export function toViewColumn(ep?: EditorPosition): theia.ViewColumn | undefined { if (typeof ep !== 'number') { @@ -876,3 +878,27 @@ export function fromColorPresentation(colorPresentation: theia.ColorPresentation additionalTextEdits: colorPresentation.additionalTextEdits ? colorPresentation.additionalTextEdits.map(value => fromTextEdit(value)) : undefined }; } + +export function quickPickItemToPickOpenItem(items: Item[]) { + const pickItems: PickOpenItem[] = []; + for (let handle = 0; handle < items.length; handle++) { + const item = items[handle]; + let label: string; + let description: string | undefined; + let detail: string | undefined; + let picked: boolean | undefined; + if (typeof item === 'string') { + label = item; + } else { + ({ label, description, detail, picked } = item); + } + pickItems.push({ + label, + description, + handle, + detail, + picked + }); + } + return pickItems; +} diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index f0f7facdcf4dc..efca850f5c076 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -1231,6 +1231,20 @@ export enum CommentThreadCollapsibleState { Expanded = 1 } +export interface QuickInputButton { + readonly iconPath: URI | { light: string | URI; dark: string | URI } | ThemeIcon; + readonly tooltip?: string | undefined; +} + +export class QuickInputButtons { + static readonly Back: QuickInputButton = { + iconPath: { + id: 'Back' + }, + tooltip: 'Back' + }; +} + export enum CommentMode { Editing = 0, Preview = 1 diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 758a8abb5ff50..d6037cfebaa5e 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -2173,6 +2173,11 @@ declare module '@theia/plugin' { * Return `undefined`, or the empty string when 'value' is valid. */ validateInput?(value: string): string | undefined | PromiseLike; + + /** + * An optional function that will be called on Enter key. + */ + onAccept?(): void; } /** @@ -3436,6 +3441,197 @@ declare module '@theia/plugin' { * @return The thenable the task-callback returned. */ export function withProgress(options: ProgressOptions, task: (progress: Progress<{ message?: string; increment?: number }>, token: CancellationToken) => PromiseLike): PromiseLike; + + /** + * Creates a [InputBox](#InputBox) to let the user enter some text input. + * + * Note that in many cases the more convenient [window.showInputBox](#window.showInputBox) + * is easier to use. [window.createInputBox](#window.createInputBox) should be used + * when [window.showInputBox](#window.showInputBox) does not offer the required flexibility. + * + * @return A new [InputBox](#InputBox). + */ + export function createInputBox(): InputBox; + } + + /** + * Predefined buttons for [QuickPick](#QuickPick) and [InputBox](#InputBox). + */ + export class QuickInputButtons { + + /** + * A back button for [QuickPick](#QuickPick) and [InputBox](#InputBox). + * + * When a navigation 'back' button is needed this one should be used for consistency. + * It comes with a predefined icon, tooltip and location. + */ + static readonly Back: QuickInputButton; + + /** + * @hidden + */ + private constructor(); + } + + /** + * A concrete [QuickInput](#QuickInput) to let the user input a text value. + * + * Note that in many cases the more convenient [window.showInputBox](#window.showInputBox) + * is easier to use. [window.createInputBox](#window.createInputBox) should be used + * when [window.showInputBox](#window.showInputBox) does not offer the required flexibility. + */ + export interface InputBox extends QuickInput { + + /** + * Current input value. + */ + value: string; + + /** + * Optional placeholder in the filter text. + */ + placeholder: string | undefined; + + /** + * If the input value should be hidden. Defaults to false. + */ + password: boolean; + + /** + * An event signaling when the value has changed. + */ + readonly onDidChangeValue: Event; + + /** + * An event signaling when the user indicated acceptance of the input value. + */ + readonly onDidAccept: Event; + + /** + * Buttons for actions in the UI. + */ + buttons: ReadonlyArray; + + /** + * An event signaling when a button was triggered. + */ + readonly onDidTriggerButton: Event; + + /** + * An optional prompt text providing some ask or explanation to the user. + */ + prompt: string | undefined; + + /** + * An optional validation message indicating a problem with the current input value. + */ + validationMessage: string | undefined; + } + + /** + * A light-weight user input UI that is initially not visible. After + * configuring it through its properties the extension can make it + * visible by calling [QuickInput.show](#QuickInput.show). + * + * There are several reasons why this UI might have to be hidden and + * the extension will be notified through [QuickInput.onDidHide](#QuickInput.onDidHide). + * (Examples include: an explicit call to [QuickInput.hide](#QuickInput.hide), + * the user pressing Esc, some other input UI opening, etc.) + * + * A user pressing Enter or some other gesture implying acceptance + * of the current state does not automatically hide this UI component. + * It is up to the extension to decide whether to accept the user's input + * and if the UI should indeed be hidden through a call to [QuickInput.hide](#QuickInput.hide). + * + * When the extension no longer needs this input UI, it should + * [QuickInput.dispose](#QuickInput.dispose) it to allow for freeing up + * any resources associated with it. + * + * See [QuickPick](#QuickPick) and [InputBox](#InputBox) for concrete UIs. + */ + export interface QuickInput { + + /** + * An optional title. + */ + title: string | undefined; + + /** + * An optional current step count. + */ + step: number | undefined; + + /** + * An optional total step count. + */ + totalSteps: number | undefined; + + /** + * If the UI should allow for user input. Defaults to true. + * + * Change this to false, e.g., while validating user input or + * loading data for the next step in user input. + */ + enabled: boolean; + + /** + * If the UI should show a progress indicator. Defaults to false. + * + * Change this to true, e.g., while loading more data or validating + * user input. + */ + busy: boolean; + + /** + * If the UI should stay open even when loosing UI focus. Defaults to false. + */ + ignoreFocusOut: boolean; + + /** + * Makes the input UI visible in its current configuration. Any other input + * UI will first fire an [QuickInput.onDidHide](#QuickInput.onDidHide) event. + */ + show(): void; + + /** + * Hides this input UI. This will also fire an [QuickInput.onDidHide](#QuickInput.onDidHide) + * event. + */ + hide(): void; + + /** + * An event signaling when this input UI is hidden. + * + * There are several reasons why this UI might have to be hidden and + * the extension will be notified through [QuickInput.onDidHide](#QuickInput.onDidHide). + * (Examples include: an explicit call to [QuickInput.hide](#QuickInput.hide), + * the user pressing Esc, some other input UI opening, etc.) + */ + onDidHide: Event; + + /** + * Dispose of this input UI and any associated resources. If it is still + * visible, it is first hidden. After this call the input UI is no longer + * functional and no additional methods or properties on it should be + * accessed. Instead a new input UI should be created. + */ + dispose(): void; + } + + /** + * Button for an action in a [QuickPick](#QuickPick) or [InputBox](#InputBox). + */ + export interface QuickInputButton { + + /** + * Icon for the button. + */ + readonly iconPath: Uri | { light: Uri; dark: Uri } | ThemeIcon; + + /** + * An optional tooltip. + */ + readonly tooltip?: string | undefined; } /** * Value-object describing where and how progress should show.