diff --git a/CHANGELOG.md b/CHANGELOG.md index c85a301cf6184..52c6307b208c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Breaking changes: - [plugin] 'Hosted mode' extracted in `plugin-dev` extension - [core] `scheme` is mandatory for URI - `URI.withoutScheme` is removed, in order to get a path use `URI.path` +- [plugin-ext] improved QuickInput and InputBox API's ## v0.7.0 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 222b430d1c77c..a46c16cee4439 100644 --- a/packages/core/src/browser/quick-open/quick-input-service.ts +++ b/packages/core/src/browser/quick-open/quick-input-service.ts @@ -21,8 +21,45 @@ 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 { QuickInputTitleBar, QuickInputTitleButton } from './quick-input-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. */ @@ -64,15 +101,32 @@ export class QuickInputService { @inject(QuickOpenService) protected readonly quickOpenService: QuickOpenService; + protected _titlePanel: QuickInputTitleBar; + protected titleBarContainer: HTMLElement; + protected titleElement: HTMLElement | undefined; + + constructor() { + this._titlePanel = new QuickInputTitleBar(); + this.createTitleBarContainer(); + } + open(options: QuickInputOptions): Promise { const result = new Deferred(); const prompt = this.createPrompt(options.prompt); let label = prompt; let currentText = ''; const validateInput = options && options.validateInput; + + this.titlePanel.title = options.title; + this.titlePanel.step = options.step; + this.titlePanel.totalSteps = options.totalSteps; + this.titlePanel.buttons = options.buttons; + + this.removeAndAttachTitleBar(); + this.quickOpenService.open({ onType: async (lookFor, acceptor) => { - const error = validateInput ? await validateInput(lookFor) : undefined; + const error = validateInput && lookFor !== undefined ? await validateInput(lookFor) : undefined; label = error || prompt; if (error) { this.quickOpenService.showDecoration(MessageType.Error); @@ -97,11 +151,70 @@ export class QuickInputService { placeholder: options.placeHolder, password: options.password, ignoreFocusOut: options.ignoreFocusOut, - onClose: () => result.resolve(undefined) + enabled: options.enabled, + onClose: () => { + result.resolve(undefined); + this.titlePanel.dispose(); + this.onDidHideEmitter.fire(undefined); + this.removeTitleElement(); + this.createTitleBarContainer(); + this.titlePanel.isAttached = false; + } }); return result.promise; } + private createTitleBarContainer(): void { + this.titleBarContainer = document.createElement('div'); + this.titleBarContainer.style.backgroundColor = 'var(--theia-menu-color0)'; + } + + setStep(step: number | undefined) { + this.titlePanel.step = step; + this.attachTitleBarIfNeeded(); + } + + setTitle(title: string | undefined) { + this.titlePanel.title = title; + this.attachTitleBarIfNeeded(); + } + + setTotalSteps(totalSteps: number | undefined) { + this.titlePanel.totalSteps = totalSteps; + } + + setButtons(buttons: ReadonlyArray) { + this.titlePanel.buttons = buttons; + this.attachTitleBarIfNeeded(); + } + + get titlePanel(): QuickInputTitleBar { + return this._titlePanel; + } + + private removeTitleElement(): void { + if (this.titleElement) { + this.titleElement.remove(); + } + } + + private attachTitleBarIfNeeded() { + if (this.titlePanel.shouldShowTitleBar() && !this.titlePanel.isAttached) { + if (!this.quickOpenService.widgetNode.contains(this.titleBarContainer)) { + this.quickOpenService.widgetNode.prepend(this.titleBarContainer); + } + this.titleElement = this.titlePanel.attachTitleBar(); + this.titleBarContainer.appendChild(this.titleElement); + this.titlePanel.isAttached = true; + } + } + + private removeAndAttachTitleBar(): void { + this.removeTitleElement(); + this.titlePanel.isAttached = false; + this.attachTitleBarIfNeeded(); + } + protected defaultPrompt = "Press 'Enter' to confirm your input or 'Escape' to cancel"; protected createPrompt(prompt?: string): string { return prompt ? `${prompt} (${this.defaultPrompt})` : this.defaultPrompt; @@ -112,4 +225,9 @@ export class QuickInputService { return this.onDidAcceptEmitter.event; } + readonly onDidHideEmitter: Emitter = new Emitter(); + get onDidHide(): Event { + return this.onDidHideEmitter.event; + } + } diff --git a/packages/core/src/browser/quick-open/quick-input-title-bar.ts b/packages/core/src/browser/quick-open/quick-input-title-bar.ts new file mode 100644 index 0000000000000..277c2e982402e --- /dev/null +++ b/packages/core/src/browser/quick-open/quick-input-title-bar.ts @@ -0,0 +1,220 @@ +/******************************************************************************** + * 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'; + +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 +} + +export class QuickInputTitleBar { + + private readonly onDidTriggerButtonEmitter: Emitter; + private _isAttached: boolean; + + private titleElement: HTMLElement; + + private _title: string | undefined; + private _step: number | undefined; + private _totalSteps: number | undefined; + private _buttons: ReadonlyArray; + + private disposableCollection: DisposableCollection; + constructor() { + this.titleElement = document.createElement('h3'); + this.titleElement.style.flex = '1'; + this.titleElement.style.textAlign = 'center'; + this.titleElement.style.margin = '5px 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 = '20px'; + aElement.style.height = '20px'; + + 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.title = btn.tooltip ? btn.tooltip : ''; + aElement.onclick = () => { + this.onDidTriggerButtonEmitter.fire(btn); + }; + buttonDiv.appendChild(aElement); + } + return buttonDiv; + } + + private createTitleBarDiv() { + const div = document.createElement('div'); + div.style.height = '1%'; // Reset the height to be valid + div.style.display = 'flex'; + div.style.flexDirection = 'row'; + div.style.flexWrap = 'wrap'; + div.style.justifyContent = 'flex-start'; + div.style.alignItems = 'center'; + 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; + } + + public attachTitleBar() { + const div = this.createTitleBarDiv(); + + this.updateInnerTitleText(); + + div.appendChild(this.createLeftButtonDiv()); + div.appendChild(this.titleElement); + div.appendChild(this.createRightButtonDiv()); + + return div; + } + + shouldShowTitleBar(): boolean { + return ((this._step !== undefined) || (this._title !== undefined)); + } + + dispose() { + this.disposableCollection.dispose(); + } + +} 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 61f68b2928e44..0b813d32ea9a2 100644 --- a/packages/core/src/browser/quick-open/quick-open-service.ts +++ b/packages/core/src/browser/quick-open/quick-open-service.ts @@ -27,6 +27,8 @@ export namespace QuickOpenOptions { enableSeparateSubstringMatching?: boolean } export interface Resolved { + readonly enabled: boolean; + readonly prefix: string; readonly placeholder: string; readonly ignoreFocusOut: boolean; @@ -55,6 +57,8 @@ export namespace QuickOpenOptions { onClose(canceled: boolean): void; } export const defaultOptions: Resolved = Object.freeze({ + enabled: true, + prefix: '', placeholder: '', ignoreFocusOut: false, @@ -86,4 +90,9 @@ export class QuickOpenService { open(model: QuickOpenModel, options?: QuickOpenOptions): void { } showDecoration(type: MessageType): void { } hideDecoration(): void { } + + /** + * Dom node of the QuickOpenWidget + */ + widgetNode: HTMLElement; } diff --git a/packages/monaco/src/browser/monaco-quick-open-service.ts b/packages/monaco/src/browser/monaco-quick-open-service.ts index f4e02614027ca..418a28677bfce 100644 --- a/packages/monaco/src/browser/monaco-quick-open-service.ts +++ b/packages/monaco/src/browser/monaco-quick-open-service.ts @@ -26,6 +26,7 @@ import { ContextKey } from '@theia/core/lib/browser/context-key-service'; import { MonacoContextKeyService } from './monaco-context-key-service'; export interface MonacoQuickOpenControllerOpts extends monaco.quickOpen.IQuickOpenControllerOpts { + enabled?: boolean; readonly prefix?: string; readonly password?: boolean; readonly ignoreFocusOut?: boolean; @@ -40,6 +41,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; @@ -92,13 +94,28 @@ 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.inQuickOpenKey.set(true); } + 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 { const widget = this.widget; if (widget.inputBox) { @@ -148,13 +165,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; @@ -205,6 +236,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; } diff --git a/packages/plugin-ext/src/api/plugin-api.ts b/packages/plugin-ext/src/api/plugin-api.ts index 205955a86e2fa..98a6b89d3ebe3 100644 --- a/packages/plugin-ext/src/api/plugin-api.ts +++ b/packages/plugin-ext/src/api/plugin-api.ts @@ -27,7 +27,8 @@ import { EndOfLine, OverviewRulerLane, IndentAction, - FileOperationOptions + FileOperationOptions, + QuickInputButton } from '../plugin/types-impl'; import { UriComponents } from '../common/uri-components'; import { ConfigurationTarget } from '../plugin/types-impl'; @@ -72,6 +73,8 @@ import { DebugProtocol } from 'vscode-debugprotocol'; import { SymbolInformation } from 'vscode-languageserver-types'; import { ScmCommand } from '@theia/scm/lib/browser'; import { ArgumentProcessor } from '../plugin/command-registry'; +import { MaybePromise } from '@theia/core/lib/common/types'; +import { QuickInputTitleButton } from '@theia/core/lib/browser/quick-open/quick-input-title-bar'; export interface PluginInitData { plugins: PluginMetadata[]; @@ -294,6 +297,8 @@ export interface QuickOpenExt { $onItemSelected(handle: number): void; $validateInput(input: string): PromiseLike | undefined; $acceptInput(): Promise; + $acceptHide(): Promise; + $acceptButton(btn: QuickInputTitleButton): Promise; } /** @@ -380,11 +385,33 @@ 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 ITransferInputBox { + title: string | undefined; + step: number | undefined; + totalSteps: number | undefined; + enabled: boolean; + busy: boolean; + ignoreFocusOut: boolean; + value: string; + placeholder: string | undefined; + password: boolean; + buttons: ReadonlyArray; + prompt: string | undefined; + validationMessage: string | undefined; + validateInput(value: string): MaybePromise; +} + export interface QuickOpenMain { $show(options: PickOptions): Promise; $setItems(items: PickOpenItem[]): Promise; $setError(error: Error): Promise; $input(options: theia.InputBoxOptions, validateInput: boolean): Promise; + $showInputBox(inputBox: ITransferInputBox): void; + $setInputBoxChanged(changed: object): 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 fa36602456b00..6da8908ec5c89 100644 --- a/packages/plugin-ext/src/main/browser/quick-open-main.ts +++ b/packages/plugin-ext/src/main/browser/quick-open-main.ts @@ -18,9 +18,13 @@ import { InputBoxOptions } from '@theia/plugin'; import { interfaces } from 'inversify'; 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 { QuickOpenExt, QuickOpenMain, MAIN_RPC_CONTEXT, PickOptions, PickOpenItem, ITransferInputBox, QuickInputTitleButtonHandle } from '../../api/plugin-api'; 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 { QuickInputTitleButtonSide } from '@theia/core/lib/browser/quick-open/quick-input-title-bar'; export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { @@ -31,6 +35,8 @@ export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { private acceptor: ((items: QuickOpenItem[]) => void) | undefined; private items: QuickOpenItem[] | undefined; + private sharedStyle: PluginSharedStyle; + private activeElement: HTMLElement | undefined; constructor(rpc: RPCProtocol, container: interfaces.Container) { @@ -38,6 +44,8 @@ export class QuickOpenMainImpl implements QuickOpenMain, QuickOpenModel { this.delegate = container.get(MonacoQuickOpenService); this.quickInput = container.get(QuickInputService); this.quickInput.onDidAccept(() => this.proxy.$acceptInput()); + this.quickInput.onDidHide(() => this.proxy.$acceptHide()); + this.sharedStyle = container.get(PluginSharedStyle); } private cleanUp() { @@ -104,6 +112,121 @@ 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): void { + inputBox.validateInput = val => this.proxy.$validateInput(val); + + 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 + }); + this.quickInput.titlePanel.onDidTriggerButton(button => { + this.proxy.$acceptButton(button); + }); + } + + // tslint:disable-next-line:no-any + private findChangedKey(key: string, value: any) { + switch (key) { + case 'title': { + this.quickInput.setTitle(value); + break; + } + case 'step': { + this.quickInput.setStep(value); + break; + } + case 'totalSteps': { + this.quickInput.setTotalSteps(value); + break; + } + case 'buttons': { + this.quickInput.setButtons(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 + $setInputBoxChanged(changed: any) { + for (const key in changed) { + if (changed.hasOwnProperty(key)) { + const value = changed[key]; + this.findChangedKey(key, value); + } + } + } + onType(lookFor: string, acceptor: (items: QuickOpenItem[]) => void): void { this.acceptor = acceptor; if (this.items) { diff --git a/packages/plugin-ext/src/main/node/plugin-resolution.ts b/packages/plugin-ext/src/main/node/plugin-resolution.ts new file mode 100644 index 0000000000000..28ce131ca2da4 --- /dev/null +++ b/packages/plugin-ext/src/main/node/plugin-resolution.ts @@ -0,0 +1,231 @@ +/******************************************************************************** + * 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 + ********************************************************************************/ + +// tslint:disable:no-any + +import { injectable, optional, multiInject, inject } from 'inversify'; +import { + PluginDeployerResolver, PluginDeployerFileHandler, PluginDeployerDirectoryHandler, + PluginDeployerEntry, PluginDeployer, PluginDeployerResolverInit, PluginDeployerFileHandlerContext, + PluginDeployerDirectoryHandlerContext, PluginDeployerEntryType, PluginDeployerHandler, +} from '../../common/plugin-protocol'; +import { PluginDeployerEntryImpl } from './plugin-deployer-entry-impl'; +import { PluginDeployerResolverContextImpl, PluginDeployerResolverInitImpl } from './plugin-deployer-resolver-context-impl'; +import { ProxyPluginDeployerEntry } from './plugin-deployer-proxy-entry-impl'; +import { PluginDeployerFileHandlerContextImpl } from './plugin-deployer-file-handler-context-impl'; +import { PluginDeployerDirectoryHandlerContextImpl } from './plugin-deployer-directory-handler-context-impl'; +import { ILogger, Emitter } from '@theia/core'; +import { PluginCliContribution } from './plugin-cli-contribution'; + +@injectable() +export class PluginResolution implements PluginDeployer { + + protected readonly onDidDeployEmitter = new Emitter(); + readonly onDidDeploy = this.onDidDeployEmitter.event; + + @inject(ILogger) + protected readonly logger: ILogger; + + @inject(PluginDeployerHandler) + protected readonly pluginDeployerHandler: PluginDeployerHandler; + + @inject(PluginCliContribution) + protected readonly cliContribution: PluginCliContribution; + + /** + * Deployer entries. + */ + private pluginDeployerEntries: PluginDeployerEntry[]; + + /** + * Inject all plugin resolvers found at runtime. + */ + @optional() @multiInject(PluginDeployerResolver) + private pluginResolvers: PluginDeployerResolver[]; + + /** + * Inject all file handler for local resolved plugins. + */ + @optional() @multiInject(PluginDeployerFileHandler) + private pluginDeployerFileHandlers: PluginDeployerFileHandler[]; + + /** + * Inject all directory handler for local resolved plugins. + */ + @optional() @multiInject(PluginDeployerDirectoryHandler) + private pluginDeployerDirectoryHandlers: PluginDeployerDirectoryHandler[]; + + public start(): void { + this.logger.debug('Starting the deployer with the list of resolvers', this.pluginResolvers); + this.doStart(); + } + + public async initResolvers(): Promise> { + + // call init on each resolver + const pluginDeployerResolverInit: PluginDeployerResolverInit = new PluginDeployerResolverInitImpl(); + const promises = this.pluginResolvers.map(async pluginResolver => { + if (pluginResolver.init) { + pluginResolver.init(pluginDeployerResolverInit); + } + }); + return Promise.all(promises); + } + + protected async doStart(): Promise { + + // init resolvers + await this.initResolvers(); + + // check THEIA_DEFAULT_PLUGINS or THEIA_PLUGINS env var + const defaultPluginsValue = process.env.THEIA_DEFAULT_PLUGINS || undefined; + const pluginsValue = process.env.THEIA_PLUGINS || undefined; + // check the `--plugins` CLI option + const defaultPluginsValueViaCli = this.cliContribution.localDir(); + + this.logger.debug('Found the list of default plugins ID on env:', defaultPluginsValue); + this.logger.debug('Found the list of plugins ID on env:', pluginsValue); + this.logger.debug('Found the list of default plugins ID from CLI:', defaultPluginsValueViaCli); + + // transform it to array + const defaultPluginIdList = defaultPluginsValue ? defaultPluginsValue.split(',') : []; + const pluginIdList = pluginsValue ? pluginsValue.split(',') : []; + const pluginsList = defaultPluginIdList.concat(pluginIdList).concat(defaultPluginsValueViaCli ? defaultPluginsValueViaCli.split(',') : []); + + await this.deployMultipleEntries(pluginsList); + + } + + public async deploy(pluginEntry: string): Promise { + await this.deployMultipleEntries([pluginEntry]); + return Promise.resolve(); + } + + protected async deployMultipleEntries(pluginEntries: string[]): Promise { + // resolve plugins + this.pluginDeployerEntries = await this.resolvePlugins(pluginEntries); + + // now that we have plugins check if we have File Handler for them + await this.applyFileHandlers(); + + // ok now ask for directory handlers + await this.applyDirectoryFileHandlers(); + + await this.deployPlugins(); + + return Promise.resolve(); + + } + + /** + * deploy all plugins that have been accepted + */ + async deployPlugins(): Promise { + const acceptedPlugins = this.pluginDeployerEntries.filter(pluginDeployerEntry => pluginDeployerEntry.isAccepted()); + const acceptedFrontendPlugins = this.pluginDeployerEntries.filter(pluginDeployerEntry => pluginDeployerEntry.isAccepted(PluginDeployerEntryType.FRONTEND)); + const acceptedBackendPlugins = this.pluginDeployerEntries.filter(pluginDeployerEntry => pluginDeployerEntry.isAccepted(PluginDeployerEntryType.BACKEND)); + + this.logger.debug('the accepted plugins are', acceptedPlugins); + this.logger.debug('the acceptedFrontendPlugins plugins are', acceptedFrontendPlugins); + this.logger.debug('the acceptedBackendPlugins plugins are', acceptedBackendPlugins); + + acceptedPlugins.forEach(plugin => { + this.logger.debug('will deploy plugin', plugin.id(), 'with changes', JSON.stringify(plugin.getChanges()), 'and this plugin has been resolved by', plugin.resolvedBy()); + }); + + // local path to launch + const pluginPaths = acceptedBackendPlugins.map(pluginEntry => pluginEntry.path()); + this.logger.debug('local path to deploy on remote instance', pluginPaths); + + await Promise.all([ + // start the backend plugins + this.pluginDeployerHandler.deployBackendPlugins(acceptedBackendPlugins), + this.pluginDeployerHandler.deployFrontendPlugins(acceptedFrontendPlugins) + ]); + this.onDidDeployEmitter.fire(undefined); + } + + /** + * If there are some single files, try to see if we can work on these files (like unpacking it, etc) + */ + public async applyFileHandlers(): Promise { + const waitPromises: Array> = []; + + this.pluginDeployerEntries.filter(pluginDeployerEntry => pluginDeployerEntry.isResolved()).map(pluginDeployerEntry => { + this.pluginDeployerFileHandlers.map(pluginFileHandler => { + const proxyPluginDeployerEntry = new ProxyPluginDeployerEntry(pluginFileHandler, (pluginDeployerEntry) as PluginDeployerEntryImpl); + if (pluginFileHandler.accept(proxyPluginDeployerEntry)) { + const pluginDeployerFileHandlerContext: PluginDeployerFileHandlerContext = new PluginDeployerFileHandlerContextImpl(proxyPluginDeployerEntry); + const promise: Promise = pluginFileHandler.handle(pluginDeployerFileHandlerContext); + waitPromises.push(promise); + } + }); + + }); + return Promise.all(waitPromises); + } + + /** + * Check for all registered directories to see if there are some plugins that can be accepted to be deployed. + */ + public async applyDirectoryFileHandlers(): Promise { + const waitPromises: Array> = []; + + this.pluginDeployerEntries.filter(pluginDeployerEntry => pluginDeployerEntry.isResolved()).map(pluginDeployerEntry => { + this.pluginDeployerDirectoryHandlers.map(pluginDirectoryHandler => { + const proxyPluginDeployerEntry = new ProxyPluginDeployerEntry(pluginDirectoryHandler, (pluginDeployerEntry) as PluginDeployerEntryImpl); + if (pluginDirectoryHandler.accept(proxyPluginDeployerEntry)) { + const pluginDeployerDirectoryHandlerContext: PluginDeployerDirectoryHandlerContext = new PluginDeployerDirectoryHandlerContextImpl(proxyPluginDeployerEntry); + const promise: Promise = pluginDirectoryHandler.handle(pluginDeployerDirectoryHandlerContext); + waitPromises.push(promise); + } + }); + + }); + return Promise.all(waitPromises); + } + + /** + * Check a given set of plugin IDs to see if there are some resolvers that can handle them. If there is a matching resolver, then we resolve the plugin + */ + public async resolvePlugins(pluginIdList: string[]): Promise { + const pluginDeployerEntries: PluginDeployerEntry[] = []; + + // check if accepted ? + const promises = pluginIdList.map(async pluginId => { + + const foundPluginResolver = this.pluginResolvers.find(pluginResolver => pluginResolver.accept(pluginId)); + // there is a resolver for the input + if (foundPluginResolver) { + + // create context object + const context = new PluginDeployerResolverContextImpl(foundPluginResolver, pluginId); + + await foundPluginResolver.resolve(context); + + context.getPlugins().forEach(entry => pluginDeployerEntries.push(entry)); + } else { + // log it for now + this.logger.error('No plugin resolver found for the entry', pluginId); + pluginDeployerEntries.push(new PluginDeployerEntryImpl(pluginId, pluginId)); + } + // you can do other stuff with the `item` here + }); + await Promise.all(promises); + + return pluginDeployerEntries; + } +} diff --git a/packages/plugin-ext/src/plugin/quick-open.ts b/packages/plugin-ext/src/plugin/quick-open.ts index 4618a161ed9d0..3d5dde10929da 100644 --- a/packages/plugin-ext/src/plugin/quick-open.ts +++ b/packages/plugin-ext/src/plugin/quick-open.ts @@ -13,14 +13,16 @@ * * 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, InputBoxOptions, InputBox, QuickInputButton, QuickPick } from '@theia/plugin'; +import { QuickOpenExt, PLUGIN_RPC_CONTEXT as Ext, QuickOpenMain, PickOpenItem, ITransferInputBox } from '../api/plugin-api'; +import { QuickPickOptions, QuickPickItem, InputBoxOptions, InputBox, QuickPick } from '@theia/plugin'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; import { RPCProtocol } from '../api/rpc-protocol'; import { anyPromise } from '../api/async-util'; import { hookCancellationToken } from '../api/async-util'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; +import { QuickInputButtons, QuickInputButton } from './types-impl'; +import { QuickInputTitleButtonHandle } from '../common'; export type Item = string | QuickPickItem; @@ -29,10 +31,15 @@ export class QuickOpenExtImpl implements QuickOpenExt { private selectItemHandler: undefined | ((handle: number) => void); private validateInputHandler: undefined | ((input: string) => string | PromiseLike | undefined); private onDidAcceptInputEmitter: Emitter; + private onDidHideEmitter: Emitter; + private onDidTriggerButtonEmitter: Emitter; + private inputBox: InputBox; constructor(rpc: RPCProtocol) { this.proxy = rpc.getProxy(Ext.QUICK_OPEN_MAIN); this.onDidAcceptInputEmitter = new Emitter(); + this.onDidHideEmitter = new Emitter(); + this.onDidTriggerButtonEmitter = new Emitter(); } $onItemSelected(handle: number): void { if (this.selectItemHandler) { @@ -130,14 +137,32 @@ export class QuickOpenExtImpl implements QuickOpenExt { return hookCancellationToken(token, promise); } + showInputBox(options: ITransferInputBox): void { + this.validateInputHandler = options && options.validateInput; + this.proxy.$showInputBox(options); + } + createInputBox(): InputBox { - return new InputBoxExt(this, this.onDidAcceptInputEmitter); + this.inputBox = new InputBoxExt(this, this.onDidAcceptInputEmitter, this.onDidHideEmitter, this.onDidTriggerButtonEmitter, this.proxy); + return this.inputBox; } async $acceptInput(): Promise { this.onDidAcceptInputEmitter.fire(undefined); } + async $acceptHide(): Promise { + this.onDidHideEmitter.fire(undefined); + } + + async $acceptButton(btn: QuickInputTitleButtonHandle): Promise { + if (btn.index === -1) { + this.onDidTriggerButtonEmitter.fire(QuickInputButtons.Back); + } else { + const btnFromIndex = this.inputBox.buttons[btn.index]; + this.onDidTriggerButtonEmitter.fire(btnFromIndex as QuickInputButton); + } + } } /** @@ -146,29 +171,39 @@ export class QuickOpenExtImpl implements QuickOpenExt { */ export class InputBoxExt implements InputBox { - busy: boolean; - buttons: ReadonlyArray; - enabled: boolean; - ignoreFocusOut: boolean; - password: boolean; - placeholder: string | undefined; - prompt: string | undefined; - step: number | undefined; - title: string | undefined; - totalSteps: number | undefined; - validationMessage: string | undefined; - value: string; + private _busy: boolean; + private _buttons: ReadonlyArray; + private _enabled: boolean; + private _ignoreFocusOut: boolean; + private _password: boolean; + private _placeholder: string | undefined; + private _prompt: string | undefined; + private _step: number | undefined; + private _title: string | undefined; + private _totalSteps: number | undefined; + private _validationMessage: string | undefined; + private _value: string; private readonly disposables: DisposableCollection; private readonly onDidChangeValueEmitter: Emitter; - private readonly onDidHideEmitter: Emitter; - private readonly onDidTriggerButtonEmitter: Emitter; + private visible: boolean; + + constructor(readonly quickOpen: QuickOpenExtImpl, + readonly onDidAcceptEmitter: Emitter, + readonly onDidHideEmitter: Emitter, + readonly onDidTriggerButtonEmitter: Emitter, + readonly quickOpenMain: QuickOpenMain) { - constructor(readonly quickOpen: QuickOpenExtImpl, readonly onDidAcceptEmitter: Emitter) { this.disposables = new DisposableCollection(); this.disposables.push(this.onDidChangeValueEmitter = new Emitter()); - this.disposables.push(this.onDidHideEmitter = new Emitter()); - this.disposables.push(this.onDidTriggerButtonEmitter = new Emitter()); + this.visible = false; + + this.buttons = []; + this.enabled = true; + this.busy = false; + this.ignoreFocusOut = false; + this.password = false; + this.value = ''; } get onDidChangeValue(): Event { @@ -180,6 +215,7 @@ export class InputBoxExt implements InputBox { } get onDidHide(): Event { + this.visible = false; return this.onDidHideEmitter.event; } @@ -187,6 +223,126 @@ export class InputBoxExt implements InputBox { return this.onDidTriggerButtonEmitter.event; } + 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 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) { + this._validationMessage = validationMessage; + this.update({ validationMessage }); + } + + get value(): string { + return this._value; + } + + set value(value: string) { + this._value = value; + this.update({ value }); + } + + protected update(changed: object): void { + /** + * 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) { + return; + } + + this.quickOpenMain.$setInputBoxChanged(changed); + } + dispose(): void { this.disposables.dispose(); } @@ -202,18 +358,26 @@ export class InputBoxExt implements InputBox { return this.validationMessage; } }; - this.quickOpen.showInput({ + this.quickOpen.showInputBox({ + busy: this.busy, + buttons: this.buttons, + enabled: this.enabled, + ignoreFocusOut: this.ignoreFocusOut, password: this.password, - placeHolder: this.placeholder, + placeholder: this.placeholder, prompt: this.prompt, + step: this.step, + title: this.title, + totalSteps: this.totalSteps, + validationMessage: this.validationMessage, value: this.value, - ignoreFocusOut: this.ignoreFocusOut, validateInput(value: string): string | undefined { if (value.length > 0) { return update(value); } } }); + this.visible = true; } } diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 4bfe4706ec041..06b09a8808ec4 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -1227,12 +1227,17 @@ export enum FileChangeType { } export interface QuickInputButton { - readonly iconPath: ThemeIcon; + readonly iconPath: URI | { light: string | URI; dark: string | URI } | ThemeIcon; readonly tooltip?: string | undefined; } export class QuickInputButtons { - static readonly Back: QuickInputButton; + static readonly Back: QuickInputButton = { + iconPath: { + id: 'Back' + }, + tooltip: 'Back' + }; } export class FileSystemError extends Error {