// ***************************************************************************** // 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-only WITH Classpath-exception-2.0 // ***************************************************************************** /* eslint-disable @typescript-eslint/no-explicit-any */ import { QuickOpenExt, PLUGIN_RPC_CONTEXT as Ext, QuickOpenMain, TransferInputBox, Plugin, TransferQuickInputButton, TransferQuickInput, TransferQuickPickItem } from '../common/plugin-api-rpc'; import * as theia from '@theia/plugin'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; import { RPCProtocol } from '../common/rpc-protocol'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { InputBoxValidationSeverity, QuickInputButtons, QuickPickItemKind, ThemeIcon } from './types-impl'; import { Severity } from '@theia/core/lib/common/severity'; import { URI } from '@theia/core/shared/vscode-uri'; import * as path from 'path'; import { convertToTransferQuickPickItems } from './type-converters'; import { PluginPackage } from '../common/plugin-protocol'; import { QuickInputButtonHandle } from '@theia/core/lib/browser'; import { MaybePromise } from '@theia/core/lib/common/types'; import { PluginIconPath } from './plugin-icon-path'; /** * Converts plugin API {@link InputBoxValidationSeverity} values to Theia's {@link Severity} enum. * * Note: These enums have different numeric values: * - {@link InputBoxValidationSeverity}: Info=1, Warning=2, Error=3 (matches Monaco/VS Code) * - {@link Severity}: Ignore=0, Error=1, Warning=2, Info=3 */ function inputBoxValidationSeverityToSeverity(severity: InputBoxValidationSeverity): Severity { switch (severity) { case InputBoxValidationSeverity.Error: return Severity.Error; case InputBoxValidationSeverity.Warning: return Severity.Warning; case InputBoxValidationSeverity.Info: return Severity.Info; default: return Severity.Ignore; } } const canceledName = 'Canceled'; /** * Checks if the given error is a promise in canceled state */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isPromiseCanceledError(error: any): boolean { return error instanceof Error && error.name === canceledName && error.message === canceledName; } type Item = theia.QuickPickItem | string; export class QuickOpenExtImpl implements QuickOpenExt { private proxy: QuickOpenMain; private onDidSelectItem: undefined | ((handle: number) => void); private validateInputHandler?: (input: string) => MaybePromise; private _sessions = new Map(); // Each quickinput will have a number so that we know where to fire events private _instances = 0; constructor(rpc: RPCProtocol) { this.proxy = rpc.getProxy(Ext.QUICK_OPEN_MAIN); } /* eslint-disable max-len */ showQuickPick(plugin: Plugin, itemsOrItemsPromise: theia.QuickPickItem[] | Promise, options: theia.QuickPickOptions & { canPickMany: true; }, token?: theia.CancellationToken): Promise | undefined>; showQuickPick(plugin: Plugin, itemsOrItemsPromise: string[] | Promise, options?: theia.QuickPickOptions, token?: theia.CancellationToken): Promise; showQuickPick(plugin: Plugin, itemsOrItemsPromise: theia.QuickPickItem[] | Promise, options?: theia.QuickPickOptions, token?: theia.CancellationToken): Promise; showQuickPick(plugin: Plugin, itemsOrItemsPromise: Item[] | Promise, options?: theia.QuickPickOptions, token: theia.CancellationToken = CancellationToken.None): Promise { this.onDidSelectItem = undefined; const itemsPromise = Promise.resolve(itemsOrItemsPromise); const instance = ++this._instances; const widgetPromise = this.proxy.$show(instance, { title: options && options.title, canPickMany: options && options.canPickMany, placeHolder: options && options.placeHolder, prompt: options && options.prompt, matchOnDescription: options && options.matchOnDescription, matchOnDetail: options && options.matchOnDetail, ignoreFocusLost: options && options.ignoreFocusOut }, token); const widgetClosedMarker = {}; const widgetClosedPromise = widgetPromise.then(() => widgetClosedMarker); return Promise.race([widgetClosedPromise, itemsPromise]).then(result => { if (result === widgetClosedMarker) { return undefined; } return itemsPromise.then(async items => { const pickItems = convertToTransferQuickPickItems(plugin, items); if (options && typeof options.onDidSelectItem === 'function') { this.onDidSelectItem = handle => { options.onDidSelectItem!(items[handle]); }; } this.proxy.$setItems(instance, pickItems); return widgetPromise.then(handle => { if (typeof handle === 'number') { if (options && options.canPickMany) { return Array.of(items[handle]); } else { return items[handle]; } } else if (Array.isArray(handle)) { return handle.map(h => items[h]); } return undefined; }); }); }).then(undefined, err => { if (isPromiseCanceledError(err)) { return undefined; } this.proxy.$setError(instance, err); return Promise.reject(err); }); } $onItemSelected(handle: number): void { if (this.onDidSelectItem) { this.onDidSelectItem(handle); } } // ---- input showInput(options?: theia.InputBoxOptions, token: theia.CancellationToken = CancellationToken.None): PromiseLike { this.validateInputHandler = options?.validateInput; if (!options) { options = { placeHolder: '' }; } return this.proxy.$input(options, typeof this.validateInputHandler === 'function', token); } async showInputBox(options: TransferInputBox): Promise { this.validateInputHandler = typeof options.validateInput === 'function' ? options.validateInput : undefined; return this.proxy.$showInputBox(options, typeof this.validateInputHandler === 'function'); } async $validateInput(input: string): Promise { if (!this.validateInputHandler) { return; } const result = await this.validateInputHandler(input); if (!result || typeof result === 'string') { return result; } return { content: result.message, severity: inputBoxValidationSeverityToSeverity(result.severity ?? InputBoxValidationSeverity.Error) }; } // ---- QuickInput createQuickPick(plugin: Plugin): theia.QuickPick { const session: any = new QuickPickExt(this, this.proxy, plugin, () => this._sessions.delete(session._id)); this._sessions.set(session._id, session); return session; } createInputBox(plugin: Plugin): theia.InputBox { const session: any = new InputBoxExt(this, this.proxy, plugin, () => this._sessions.delete(session._id)); this._sessions.set(session._id, session); return session; } hide(): void { this.proxy.$hide(); } async $acceptOnDidAccept(sessionId: number): Promise { const session = this._sessions.get(sessionId); if (session) { session._fireAccept(); } } async $acceptDidChangeValue(sessionId: number, changedValue: string): Promise { const session = this._sessions.get(sessionId); if (session) { session._fireChangedValue(changedValue); } } async $acceptOnDidHide(sessionId: number): Promise { const session = this._sessions.get(sessionId); if (session) { session._fireHide(); } } async $acceptOnDidTriggerButton(sessionId: number, btn: QuickInputButtonHandle): Promise { const session = this._sessions.get(sessionId); if (session) { if (btn.handle === -1) { session._fireButtonTrigger(QuickInputButtons.Back); } else if (session && (session instanceof InputBoxExt || session instanceof QuickPickExt)) { const btnFromHandle = session.buttons[btn.handle]; session._fireButtonTrigger(btnFromHandle as theia.QuickInputButton); } } } $onDidChangeActive(sessionId: number, handles: number[]): void { const session = this._sessions.get(sessionId); if (session instanceof QuickPickExt) { session._fireDidChangeActive(handles); } } $onDidChangeSelection(sessionId: number, handles: number[]): void { const session = this._sessions.get(sessionId); if (session instanceof QuickPickExt) { session._fireDidChangeSelection(handles); } } $onDidTriggerItemButton(sessionId: number, itemHandle: number, buttonHandle: number): void { const session = this._sessions.get(sessionId); if (session instanceof QuickPickExt) { session._fireDidTriggerItemButton(itemHandle, buttonHandle); } } } export class QuickInputExt implements theia.QuickInput { private static _nextId = 1; _id = QuickInputExt._nextId++; private _busy: boolean; private _enabled: boolean; private _ignoreFocusOut: boolean; private _step: number | undefined; private _title: string | undefined; private _totalSteps: number | undefined; private _value: string; private _placeholder: string | undefined; private _prompt: string | undefined; private _buttons: theia.QuickInputButton[] = []; private _handlesToButtons = new Map(); protected expectingHide = false; protected visible: boolean; private _disposed = false; protected disposableCollection: DisposableCollection; private onDidAcceptEmitter: Emitter; /** * it has to be named `_onDidChangeValueEmitter`, since Gitlens extension relies on it * https://github.com/eamodio/vscode-gitlens/blob/f22a9cd4199ac498c217643282a6a412e1fc01ae/src/commands/gitCommands.ts#L242-L243 */ private _onDidChangeValueEmitter: Emitter; private onDidHideEmitter: Emitter; private onDidTriggerButtonEmitter: Emitter; private _updateTimeout: any; private _pendingUpdate: TransferQuickInput = { id: this._id }; constructor(readonly quickOpen: QuickOpenExtImpl, readonly quickOpenMain: QuickOpenMain, readonly plugin: Plugin, private _onDidDispose: () => void) { 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 }); } 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 buttons(): theia.QuickInputButton[] { return this._buttons; } set buttons(buttons: theia.QuickInputButton[]) { this._buttons = buttons.slice(); this._handlesToButtons.clear(); buttons.forEach((button, i) => { const handle = button === QuickInputButtons.Back ? -1 : i; this._handlesToButtons.set(handle, button); }); this.update({ buttons: buttons.map((button, i) => ({ iconUrl: PluginIconPath.toUrl(button.iconPath, this.plugin) ?? ThemeIcon.get(button.iconPath), tooltip: button.tooltip, handle: button === QuickInputButtons.Back ? -1 : i, location: button.location, toggle: button.toggle, })) }); } show(): void { this.visible = true; this.expectingHide = true; this.update({ visible: true }); } dispose(): void { if (this._disposed) { return; } this._disposed = true; this._fireHide(); this.disposableCollection.dispose(); this._onDidDispose(); this.quickOpenMain.$dispose(this._id); } // eslint-disable-next-line @typescript-eslint/no-explicit-any protected update(properties: Record): void { if (this._disposed) { return; } for (const key of Object.keys(properties)) { const value = properties[key]; this._pendingUpdate[key] = value === undefined ? null : value; } if ('visible' in this._pendingUpdate) { if (this._updateTimeout) { clearTimeout(this._updateTimeout); this._updateTimeout = undefined; } this.dispatchUpdate(); } else if (this.visible && !this._updateTimeout) { // Defer the update so that multiple changes to setters dont cause a redraw each this._updateTimeout = setTimeout(() => { this._updateTimeout = undefined; this.dispatchUpdate(); }, 0); } } private dispatchUpdate(): void { this.quickOpenMain.$createOrUpdate(this._pendingUpdate); this._pendingUpdate = { id: this._id }; } hide(): void { this.quickOpenMain.$hide(); } 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 (ThemeIcon.is(iconPath)) { 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(): void { this.onDidAcceptEmitter.fire(undefined); } _fireChangedValue(changedValue: string): void { this._value = changedValue; this._onDidChangeValueEmitter.fire(changedValue); } _fireHide(): void { if (this.expectingHide) { this.expectingHide = false; this.onDidHideEmitter.fire(undefined); } } _fireButtonTrigger(btn: theia.QuickInputButton): void { 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/eclipse-theia/theia/issues/5109 */ export class InputBoxExt extends QuickInputExt implements theia.InputBox { private _password: boolean; private _valueSelection: readonly [number, number] | undefined; private _validationMessage: string | theia.InputBoxValidationMessage | undefined; constructor( override readonly quickOpen: QuickOpenExtImpl, override readonly quickOpenMain: QuickOpenMain, override readonly plugin: Plugin, onDispose: () => void ) { super(quickOpen, quickOpenMain, plugin, onDispose); this.buttons = []; this.password = false; this.value = ''; } get password(): boolean { return this._password; } set password(password: boolean) { this._password = password; this.update({ password }); } get valueSelection(): readonly [number, number] | undefined { return this._valueSelection; } set valueSelection(valueSelection: readonly [number, number] | undefined) { this._valueSelection = valueSelection; this.update({ valueSelection }); } get validationMessage(): string | theia.InputBoxValidationMessage | undefined { return this._validationMessage; } set validationMessage(validationMessage: string | theia.InputBoxValidationMessage | undefined) { if (this._validationMessage !== validationMessage) { this._validationMessage = validationMessage; if (typeof validationMessage === 'object' && validationMessage) { this.update({ validationMessage: validationMessage.message, severity: inputBoxValidationSeverityToSeverity(validationMessage.severity) }); } else { this.update({ validationMessage, severity: validationMessage ? Severity.Error : Severity.Ignore }); } } } } /** * Base implementation of {@link QuickPick} that uses {@link QuickOpenExt}. * Missing functionality is going to be implemented in the scope of https://github.com/eclipse-theia/theia/issues/5059 */ export class QuickPickExt extends QuickInputExt implements theia.QuickPick { private _items: T[] = []; private _handlesToItems = new Map(); private _itemsToHandles = new Map(); private _canSelectMany = false; private _matchOnDescription = true; private _matchOnDetail = true; private _sortByLabel = true; private _keepScrollPosition = false; private _activeItems: T[] = []; private _selectedItems: T[] = []; private readonly _onDidChangeActiveEmitter = new Emitter(); private readonly _onDidChangeSelectionEmitter = new Emitter(); private readonly _onDidTriggerItemButtonEmitter = new Emitter>(); constructor( override readonly quickOpen: QuickOpenExtImpl, override readonly quickOpenMain: QuickOpenMain, override readonly plugin: Plugin, onDispose: () => void ) { super(quickOpen, quickOpenMain, plugin, onDispose); this.buttons = []; this.disposableCollection.push(this._onDidChangeActiveEmitter); this.disposableCollection.push(this._onDidChangeSelectionEmitter); this.disposableCollection.push(this._onDidTriggerItemButtonEmitter); this.update({ type: 'quickPick' }); } get items(): T[] { return this._items; } set items(items: T[]) { this._items = items.slice(); this._handlesToItems.clear(); this._itemsToHandles.clear(); items.forEach((item, i) => { this._handlesToItems.set(i, item); this._itemsToHandles.set(item, i); }); const pickItems: TransferQuickPickItem[] = []; for (let handle = 0; handle < items.length; handle++) { const item = items[handle]; if (item.kind === QuickPickItemKind.Separator) { pickItems.push({ kind: 'separator', label: item.label, handle }); } else { pickItems.push({ kind: 'item', label: item.label, iconUrl: PluginIconPath.toUrl(item.iconPath, this.plugin) ?? ThemeIcon.get(item.iconPath), description: item.description, handle, detail: item.detail, picked: item.picked, alwaysShow: item.alwaysShow, buttons: item.buttons?.map((button, index) => ({ iconUrl: PluginIconPath.toUrl(button.iconPath, this.plugin) ?? ThemeIcon.get(button.iconPath), tooltip: button.tooltip, handle: button === QuickInputButtons.Back ? -1 : index, })) }); } } this.update({ items: pickItems, }); } get canSelectMany(): boolean { return this._canSelectMany; } set canSelectMany(canSelectMany: boolean) { this._canSelectMany = canSelectMany; this.update({ canSelectMany }); } get matchOnDescription(): boolean { return this._matchOnDescription; } set matchOnDescription(matchOnDescription: boolean) { this._matchOnDescription = matchOnDescription; this.update({ matchOnDescription }); } get matchOnDetail(): boolean { return this._matchOnDetail; } set matchOnDetail(matchOnDetail: boolean) { this._matchOnDetail = matchOnDetail; this.update({ matchOnDetail }); } get sortByLabel(): boolean { return this._sortByLabel; } set sortByLabel(sortByLabel: boolean) { this._sortByLabel = sortByLabel; this.update({ sortByLabel }); } get keepScrollPosition(): boolean { return this._keepScrollPosition; } set keepScrollPosition(keepScrollPosition: boolean) { this._keepScrollPosition = keepScrollPosition; this.update({ keepScrollPosition }); } get activeItems(): T[] { return this._activeItems; } set activeItems(activeItems: T[]) { this._activeItems = activeItems.filter(item => this._itemsToHandles.has(item)); this.update({ activeItems: this._activeItems.map(item => this._itemsToHandles.get(item)) }); } onDidChangeActive = this._onDidChangeActiveEmitter.event; get selectedItems(): T[] { return this._selectedItems; } set selectedItems(selectedItems: T[]) { this._selectedItems = selectedItems.filter(item => this._itemsToHandles.has(item)); this.update({ selectedItems: this._selectedItems.map(item => this._itemsToHandles.get(item)) }); } onDidChangeSelection = this._onDidChangeSelectionEmitter.event; _fireDidChangeActive(handles: number[]): void { const items = handles.map(handle => this._handlesToItems.get(handle)).filter(e => !!e) as T[]; this._activeItems = items; this._onDidChangeActiveEmitter.fire(items); } _fireDidChangeSelection(handles: number[]): void { const items = handles.map(handle => this._handlesToItems.get(handle)).filter(e => !!e) as T[]; this._selectedItems = items; this._onDidChangeSelectionEmitter.fire(items); } onDidTriggerItemButton = this._onDidTriggerItemButtonEmitter.event; _fireDidTriggerItemButton(itemHandle: number, buttonHandle: number): void { const item = this._handlesToItems.get(itemHandle)!; if (!item || !item.buttons || !item.buttons.length) { return; } const button = item.buttons[buttonHandle]; if (button) { this._onDidTriggerItemButtonEmitter.fire({ button, item }); } } }