From 85cd481c5bc6b64aab06138d71beaa78bb57d343 Mon Sep 17 00:00:00 2001 From: Igor Vinokur Date: Thu, 29 Nov 2018 13:09:44 +0200 Subject: [PATCH] plugin-2802: Introduce window.withProgress API endpoint Signed-off-by: Igor Vinokur --- .../src/common/message-service-protocol.ts | 77 ++++++++++++++-- packages/core/src/common/message-service.ts | 43 ++++++++- .../browser/notifications-message-client.ts | 72 ++++++++++++--- .../messages/src/browser/notifications.ts | 84 +++++++++++++++-- .../src/browser/style/notifications.css | 22 ++++- packages/plugin-ext/src/api/plugin-api.ts | 25 +++++ .../src/main/browser/main-context.ts | 4 + .../src/main/browser/notification-main.ts | 69 ++++++++++++++ .../status-bar-message-registry-main.ts | 22 +++-- .../plugin-ext/src/plugin/notification.ts | 92 +++++++++++++++++++ .../plugin-ext/src/plugin/plugin-context.ts | 22 +++++ packages/plugin-ext/src/plugin/statusBar.ts | 83 +++++++++++++++++ packages/plugin-ext/src/plugin/types-impl.ts | 46 ++++++++++ packages/plugin/src/theia.d.ts | 71 ++++++++++++++ 14 files changed, 692 insertions(+), 40 deletions(-) create mode 100644 packages/plugin-ext/src/main/browser/notification-main.ts create mode 100644 packages/plugin-ext/src/plugin/notification.ts create mode 100644 packages/plugin-ext/src/plugin/statusBar.ts diff --git a/packages/core/src/common/message-service-protocol.ts b/packages/core/src/common/message-service-protocol.ts index eec1042fbd707..955bcf2ba08bf 100644 --- a/packages/core/src/common/message-service-protocol.ts +++ b/packages/core/src/common/message-service-protocol.ts @@ -16,6 +16,7 @@ import { injectable, inject } from 'inversify'; import { ILogger } from './logger'; +import { CancellationToken } from './cancellation'; export const messageServicePath = '/services/messageService'; @@ -23,18 +24,51 @@ export enum MessageType { Error = 1, Warning = 2, Info = 3, - Log = 4 + Log = 4, + Progress = 5 } export interface Message { - type: MessageType; - text: string; - actions?: string[]; - options?: MessageOptions; + readonly type?: MessageType; + readonly text: string; + readonly actions?: string[]; + readonly options?: MessageOptions; +} + +export interface ProgressMessage extends Message { + readonly type?: MessageType.Progress; + readonly options?: ProgressMessageOptions; +} +export namespace ProgressMessage { + export const Cancel = 'Cancel'; + export function isCancelable(message: ProgressMessage): boolean { + return !message.options + || message.options.cancelable === undefined + || message.options.cancelable === true; + } } export interface MessageOptions { - timeout?: number; + readonly timeout?: number; +} + +export interface ProgressMessageOptions extends MessageOptions { + /** + * Default: `true` + */ + readonly cancelable?: boolean; +} + +export interface Progress { + readonly id: string; + readonly report: (update: ProgressUpdate) => void; + readonly cancel: () => void; + readonly result: Promise; +} + +export interface ProgressUpdate { + readonly message?: string; + readonly work?: { done: number, total: number }; } @injectable() @@ -53,6 +87,25 @@ export class MessageClient { this.logger.info(message.text); return Promise.resolve(undefined); } + + /** + * Show progress message with possible actions to user. + * + * To be implemented by an extension, e.g. by the messages extension. + */ + showProgress(progressId: string, message: ProgressMessage, cancellationToken: CancellationToken): Promise { + this.logger.info(message.text); + return Promise.resolve(undefined); + } + + /** + * Update started progress message. + * + * To be implemented by an extension, e.g. by the messages extension. + */ + reportProgress(progressId: string, update: ProgressUpdate, message: ProgressMessage, cancellationToken: CancellationToken): Promise { + return Promise.resolve(undefined); + } } @injectable() @@ -66,4 +119,16 @@ export class DispatchingMessageClient extends MessageClient { )); } + showProgress(progressId: string, message: ProgressMessage, cancellationToken: CancellationToken): Promise { + return Promise.race([...this.clients].map(client => + client.showProgress(progressId, message, cancellationToken) + )); + } + + reportProgress(progressId: string, update: ProgressUpdate, message: ProgressMessage, cancellationToken: CancellationToken): Promise { + return Promise.race([...this.clients].map(client => + client.reportProgress(progressId, update, message, cancellationToken) + )); + } + } diff --git a/packages/core/src/common/message-service.ts b/packages/core/src/common/message-service.ts index 891525f30c74a..d8687b7a586be 100644 --- a/packages/core/src/common/message-service.ts +++ b/packages/core/src/common/message-service.ts @@ -15,7 +15,15 @@ ********************************************************************************/ import { injectable, inject } from 'inversify'; -import { MessageClient, MessageType, MessageOptions } from './message-service-protocol'; +import { + MessageClient, + MessageType, + MessageOptions, + Progress, + ProgressUpdate, + ProgressMessage +} from './message-service-protocol'; +import { CancellationTokenSource } from './cancellation'; @injectable() export class MessageService { @@ -65,4 +73,37 @@ export class MessageService { return this.client.showMessage({ type, text }); } + async showProgress(message: ProgressMessage, onDidCancel?: () => void): Promise { + const id = this.newProgressId(); + const cancellationSource = new CancellationTokenSource(); + const report = (update: ProgressUpdate) => { + this.client.reportProgress(id, update, message, cancellationSource.token); + }; + let clientMessage = message; + if (ProgressMessage.isCancelable(message)) { + const actions = new Set(message.actions); + actions.add(ProgressMessage.Cancel); + clientMessage = { ...message, actions: Array.from(actions) }; + } + const result = this.client.showProgress(id, clientMessage, cancellationSource.token); + if (ProgressMessage.isCancelable(message) && typeof onDidCancel === 'function') { + result.then(value => { + if (value === ProgressMessage.Cancel) { + onDidCancel(); + } + }); + } + return { + id, + cancel: () => cancellationSource.cancel(), + result, + report + }; + } + + private progressIdPrefix = Math.random().toString(36).substring(5); + private counter = 0; + protected newProgressId(): string { + return `${this.progressIdPrefix}-${++this.counter}`; + } } diff --git a/packages/messages/src/browser/notifications-message-client.ts b/packages/messages/src/browser/notifications-message-client.ts index 2291dea498be1..ca24925de3872 100644 --- a/packages/messages/src/browser/notifications-message-client.ts +++ b/packages/messages/src/browser/notifications-message-client.ts @@ -18,9 +18,12 @@ import { injectable, inject } from 'inversify'; import { MessageClient, MessageType, - Message + Message, + ProgressMessage, + ProgressUpdate, + CancellationToken } from '@theia/core/lib/common'; -import { Notifications, NotificationAction } from './notifications'; +import { Notifications, NotificationAction, NotificationProperties, ProgressNotification} from './notifications'; import { NotificationPreferences } from './notification-preferences'; @injectable() @@ -33,7 +36,46 @@ export class NotificationsMessageClient extends MessageClient { return this.show(message); } + showProgress(progressId: string, message: ProgressMessage, cancellationToken: CancellationToken, update?: ProgressUpdate): Promise { + const messageArguments = { ...message, type: MessageType.Progress, options: { ...(message.options || {}), timeout: 0 } }; + if (this.visibleProgressNotifications.has(progressId)) { + throw new Error('Cannot show new progress with already existing id.'); + } + return new Promise(resolve => { + const progressNotification = this.notifications.create(this.getNotificationProperties(progressId, messageArguments, action => { + this.visibleProgressNotifications.delete(progressId); + resolve(action); + })); + this.visibleProgressNotifications.set(progressId, progressNotification); + progressNotification.show(); + if (update) { + progressNotification.update(update); + } + const cancel = () => { + if (message.options && message.options.cancelable) { + resolve(ProgressMessage.Cancel); + } + progressNotification.close(); + }; + if (cancellationToken.isCancellationRequested) { + cancel(); + } else { + cancellationToken.onCancellationRequested(cancel); + } + }); + } + + async reportProgress(progressId: string, update: ProgressUpdate, message: ProgressMessage, cancellationToken: CancellationToken): Promise { + const notification = this.visibleProgressNotifications.get(progressId); + if (notification) { + notification.update(update); + } else { + this.showProgress(progressId, message, cancellationToken, update); + } + } + protected visibleMessages = new Set(); + protected visibleProgressNotifications = new Map(); protected show(message: Message): Promise { const key = this.getKey(message); if (this.visibleMessages.has(key)) { @@ -41,10 +83,10 @@ export class NotificationsMessageClient extends MessageClient { } this.visibleMessages.add(key); return new Promise(resolve => { - this.showToast(message, a => { + this.notifications.show(this.getNotificationProperties(key, message, action => { this.visibleMessages.delete(key); - resolve(a); - }); + resolve(action); + })); }); } @@ -52,7 +94,7 @@ export class NotificationsMessageClient extends MessageClient { return `${m.type}-${m.text}-${m.actions ? m.actions.join('|') : '|'}`; } - protected showToast(message: Message, onCloseFn: (action: string | undefined) => void): void { + protected getNotificationProperties(id: string, message: Message, onCloseFn: (action: string | undefined) => void): NotificationProperties { const icon = this.iconFor(message.type); const text = message.text; const actions = (message.actions || []).map(action => { @@ -69,22 +111,22 @@ export class NotificationsMessageClient extends MessageClient { label: 'Close', fn: element => onCloseFn(undefined) }); - this.notifications.show({ + return { + id, icon, text, actions, timeout, onTimeout: () => onCloseFn(undefined) - }); + }; } - protected iconFor(type: MessageType): string { - if (type === MessageType.Error) { - return 'error'; - } - if (type === MessageType.Warning) { - return 'warning'; + protected iconFor(type: MessageType | undefined): string { + switch (type) { + case MessageType.Error: return 'error'; + case MessageType.Warning: return 'warning'; + case MessageType.Progress: return 'progress'; + default: return 'info'; } - return 'info'; } } diff --git a/packages/messages/src/browser/notifications.ts b/packages/messages/src/browser/notifications.ts index 016d3a3727fd9..8eeb83771282c 100644 --- a/packages/messages/src/browser/notifications.ts +++ b/packages/messages/src/browser/notifications.ts @@ -26,6 +26,7 @@ export interface NotificationAction { } export interface NotificationProperties { + id: string; icon: string; text: string; actions?: NotificationAction[]; @@ -38,6 +39,12 @@ export interface Notification { element: Element; } +export interface ProgressNotification { + show(): void; + close(): void; + update(item: { message?: string, work?: { done: number, total: number } }): void; +} + export class Notifications { protected container: Element; @@ -52,6 +59,10 @@ export class Notifications { this.container.appendChild(notificationElement); } + create(properties: NotificationProperties): ProgressNotification { + return new ProgressNotificationImpl(this.container, this.createNotificationElement(properties), properties); + } + protected createNotificationsContainer(parentContainer: Element): Element { const container = document.createElement('div'); container.classList.add(NOTIFICATIONS_CONTAINER); @@ -62,18 +73,30 @@ export class Notifications { const fragment = document.createDocumentFragment(); const element = fragment.appendChild(document.createElement('div')); element.classList.add(NOTIFICATION); + element.id = 'notification-container-' + properties.id; const iconContainer = element.appendChild(document.createElement('div')); iconContainer.classList.add(ICON); const icon = iconContainer.appendChild(document.createElement('i')); - icon.classList.add('fa', this.toIconClass(properties.icon), 'fa-fw', properties.icon); + icon.classList.add( + 'fa', + this.toIconClass(properties.icon), + ); + if (properties.icon === 'progress') { + icon.classList.add('fa-pulse'); + } + icon.classList.add( + 'fa-fw', + properties.icon + ); const textContainer = element.appendChild(document.createElement('div')); textContainer.classList.add(TEXT); const text = textContainer.appendChild(document.createElement('p')); + text.id = 'notification-text-' + properties.id; text.innerText = properties.text; + const handler = { element, properties }; const close = () => { element.remove(); }; - const handler = { element, properties }; const buttons = element.appendChild(document.createElement('div')); buttons.classList.add(BUTTONS); @@ -100,13 +123,60 @@ export class Notifications { } protected toIconClass(icon: string): string { - if (icon === 'error') { - return 'fa-times-circle'; + switch (icon) { + case 'error': return 'fa-times-circle'; + case 'warning': return 'fa-warning'; + case 'progress': return 'fa-spinner'; + default: return 'fa-info-circle'; } - if (icon === 'warning') { - return 'fa-warning'; + } + +} + +class ProgressNotificationImpl implements ProgressNotification { + private readonly node: Node; + private readonly container: Element; + private readonly properties: NotificationProperties; + + constructor(container: Element, node: Node, properties: NotificationProperties) { + this.node = node; + this.container = container; + this.properties = properties; + } + + close(): void { + const element = document.getElementById('notification-container-' + this.properties.id); + if (!element) { + return; + } + element.remove(); + } + + show(): void { + let container = document.getElementById('notification-container-' + this.properties.id); + if (!container) { + this.container.appendChild(this.node); + } + container = document.getElementById('notification-container-' + this.properties.id); + if (container) { + const progressContainer = container.appendChild(document.createElement('div')); + progressContainer.className = 'progress'; + const progress = progressContainer.appendChild(document.createElement('p')); + progress.id = 'notification-progress-' + this.properties.id; } - return 'fa-info-circle'; } + update(item: { message?: string, work?: { done: number, total: number } }): void { + const textElement = document.getElementById('notification-text-' + this.properties.id); + if (textElement) { + if (item.work) { + const progressElement = document.getElementById('notification-progress-' + this.properties.id); + if (progressElement) { + const progressRate = item.work; + progressElement.innerText = `${Math.floor(progressRate.done / progressRate.total * 100)}%`; + } + } + textElement.innerText = this.properties.text + (item.message ? ': ' + item.message : ''); + } + } } diff --git a/packages/messages/src/browser/style/notifications.css b/packages/messages/src/browser/style/notifications.css index e8d4ec6faef14..5e1f4d39f05ab 100644 --- a/packages/messages/src/browser/style/notifications.css +++ b/packages/messages/src/browser/style/notifications.css @@ -77,8 +77,26 @@ color: var(--theia-warn-color0); } -.theia-Notification .text { +.theia-Notification .progress { order: 2; + width: 35px; + align-items: center; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + align-self: center; + height: 100%; +} + +.theia-Notification .progress > p { + margin: 0px; + font-size: var(--theia-ui-font-size1); + vertical-align: middle; +} + +.theia-Notification .text { + order: 3; display: flex; align-items: center; justify-items: left; @@ -104,7 +122,7 @@ .theia-Notification .buttons { display: flex; flex-direction: row; - order: 3; + order: 4; white-space: nowrap; align-self: flex-end; height: 40px; diff --git a/packages/plugin-ext/src/api/plugin-api.ts b/packages/plugin-ext/src/api/plugin-api.ts index 878636f248d01..b0fc36a4f267f 100644 --- a/packages/plugin-ext/src/api/plugin-api.ts +++ b/packages/plugin-ext/src/api/plugin-api.ts @@ -51,6 +51,7 @@ import { TextDocumentShowOptions } from './model'; import { ExtPluginApi } from '../common/plugin-ext-api-contribution'; +import { CancellationToken, Progress, ProgressOptions } from '@theia/plugin'; export interface PluginInitData { plugins: PluginMetadata[]; @@ -234,6 +235,7 @@ export interface StatusBarMessageRegistryMain { color: string | undefined, tooltip: string | undefined, command: string | undefined): PromiseLike; + $update(id: string, message: string): void; $dispose(id: string): void; } @@ -403,6 +405,27 @@ export interface WindowStateExt { $onWindowStateChanged(focus: boolean): void; } +export interface NotificationExt { + withProgress( + options: ProgressOptions, + task: (progress: Progress<{ message?: string; increment?: number }>, token: CancellationToken) => PromiseLike + ): PromiseLike; + $onCancel(id: string): void; +} + +export interface NotificationMain { + $startProgress(message: string): Promise; + $stopProgress(id: string): void; + $updateProgress(message: string, item: { message?: string, increment?: number }): void; +} + +export interface StatusBarExt { + withProgress( + options: ProgressOptions, + task: (progress: Progress<{ message?: string; increment?: number }>, token: CancellationToken) => PromiseLike + ): PromiseLike; +} + export enum EditorPosition { ONE = 0, TWO = 1, @@ -807,6 +830,7 @@ export const PLUGIN_RPC_CONTEXT = { DOCUMENTS_MAIN: createProxyIdentifier('DocumentsMain'), STATUS_BAR_MESSAGE_REGISTRY_MAIN: >createProxyIdentifier('StatusBarMessageRegistryMain'), ENV_MAIN: createProxyIdentifier('EnvMain'), + NOTIFICATION_MAIN: createProxyIdentifier('NotificationMain'), TERMINAL_MAIN: createProxyIdentifier('TerminalServiceMain'), TREE_VIEWS_MAIN: createProxyIdentifier('TreeViewsMain'), PREFERENCE_REGISTRY_MAIN: createProxyIdentifier('PreferenceRegistryMain'), @@ -820,6 +844,7 @@ export const MAIN_RPC_CONTEXT = { COMMAND_REGISTRY_EXT: createProxyIdentifier('CommandRegistryExt'), QUICK_OPEN_EXT: createProxyIdentifier('QuickOpenExt'), WINDOW_STATE_EXT: createProxyIdentifier('WindowStateExt'), + NOTIFICATION_EXT: createProxyIdentifier('NotificationExt'), WORKSPACE_EXT: createProxyIdentifier('WorkspaceExt'), TEXT_EDITORS_EXT: createProxyIdentifier('TextEditorsExt'), EDITORS_AND_DOCUMENTS_EXT: createProxyIdentifier('EditorsAndDocumentsExt'), diff --git a/packages/plugin-ext/src/main/browser/main-context.ts b/packages/plugin-ext/src/main/browser/main-context.ts index 73447e8b026d1..3c66eaf6a575c 100644 --- a/packages/plugin-ext/src/main/browser/main-context.ts +++ b/packages/plugin-ext/src/main/browser/main-context.ts @@ -30,6 +30,7 @@ import { TerminalServiceMainImpl } from './terminal-main'; import { LanguagesMainImpl } from './languages-main'; import { DialogsMainImpl } from './dialogs-main'; import { TreeViewsMainImpl } from './view/tree-views-main'; +import { NotificationMainImpl } from './notification-main'; import { ConnectionMainImpl } from './connection-main'; export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container): void { @@ -62,6 +63,9 @@ export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container const envMain = new EnvMainImpl(rpc, container); rpc.set(PLUGIN_RPC_CONTEXT.ENV_MAIN, envMain); + const notificationMain = new NotificationMainImpl(rpc, container); + rpc.set(PLUGIN_RPC_CONTEXT.NOTIFICATION_MAIN, notificationMain); + const terminalMain = new TerminalServiceMainImpl(rpc, container); rpc.set(PLUGIN_RPC_CONTEXT.TERMINAL_MAIN, terminalMain); diff --git a/packages/plugin-ext/src/main/browser/notification-main.ts b/packages/plugin-ext/src/main/browser/notification-main.ts new file mode 100644 index 0000000000000..9105931786309 --- /dev/null +++ b/packages/plugin-ext/src/main/browser/notification-main.ts @@ -0,0 +1,69 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { MAIN_RPC_CONTEXT, NotificationExt, NotificationMain } from '../../api/plugin-api'; +import { MessageService, Progress } from '@theia/core/lib/common'; +import { interfaces } from 'inversify'; +import { RPCProtocol } from '../../api/rpc-protocol'; +import { Deferred } from '@theia/core/lib/common/promise-util'; + +export class NotificationMainImpl implements NotificationMain { + + private readonly proxy: NotificationExt; + private readonly messageService: MessageService; + private readonly progressMap = new Map(); + private readonly incrementMap = new Map(); + + constructor(rpc: RPCProtocol, container: interfaces.Container) { + this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.NOTIFICATION_EXT); + this.messageService = container.get(MessageService); + } + + async $startProgress(message: string): Promise { + const deferredId = new Deferred(); + const onDidClose = async () => this.proxy.$onCancel(await deferredId.promise); + const progress = await this.messageService.showProgress({ text: message, options: { cancelable: true } }, onDidClose); + deferredId.resolve(progress.id); + this.progressMap.set(progress.id, progress); + this.incrementMap.set(progress.id, 0); + return progress.id; + } + + $stopProgress(id: string): void { + const progress = this.progressMap.get(id); + if (progress) { + progress.cancel(); + this.progressMap.delete(id); + this.incrementMap.delete(id); + } + } + + $updateProgress(id: string, item: { message?: string, increment?: number }): void { + const progress = this.progressMap.get(id); + let done: number | undefined; + if (item.increment) { + const increment = this.incrementMap.get(id); + if (increment !== undefined) { + done = increment + item.increment; + done = done > 100 ? 100 : done; + this.incrementMap.set(id, done); + } + } + if (progress) { + progress.report({ message: item.message, work: done ? { done, total: 100 } : undefined }); + } + } +} diff --git a/packages/plugin-ext/src/main/browser/status-bar-message-registry-main.ts b/packages/plugin-ext/src/main/browser/status-bar-message-registry-main.ts index ae37b0dd5ca19..08b54c7ec52dd 100644 --- a/packages/plugin-ext/src/main/browser/status-bar-message-registry-main.ts +++ b/packages/plugin-ext/src/main/browser/status-bar-message-registry-main.ts @@ -16,14 +16,14 @@ import {interfaces} from 'inversify'; import * as types from '../../plugin/types-impl'; import {StatusBarMessageRegistryMain} from '../../api/plugin-api'; -import {StatusBar, StatusBarAlignment} from '@theia/core/lib/browser/status-bar/status-bar'; +import { StatusBar, StatusBarAlignment, StatusBarEntry } from '@theia/core/lib/browser/status-bar/status-bar'; const STATUS_BAR_MESSAGE_PRE = 'status-bar-entry'; export class StatusBarMessageRegistryMainImpl implements StatusBarMessageRegistryMain { private delegate: StatusBar; - private ids: string[] = []; + private entries: Map = new Map(); constructor(container: interfaces.Container) { this.delegate = container.get(StatusBar); @@ -36,8 +36,6 @@ export class StatusBarMessageRegistryMainImpl implements StatusBarMessageRegistr tooltip: string | undefined, command: string | undefined): PromiseLike { const id = this.uniqueId; - this.ids.push(id); - const entry = { text, priority, @@ -47,15 +45,21 @@ export class StatusBarMessageRegistryMainImpl implements StatusBarMessageRegistr command }; + this.entries.set(id, entry); return this.delegate.setElement(id, entry).then(() => Promise.resolve(id)); } + $update(id: string, message: string): void { + const entry = this.entries.get(id); + if (entry) { + entry.text = message; + this.delegate.setElement(id, entry); + } + } + $dispose(id: string): void { this.delegate.removeElement(id).then(() => { - const index = this.ids.indexOf(id); - if (index > -1) { - this.ids.splice(index, 1); - } + this.entries.delete(id); }); } @@ -63,7 +67,7 @@ export class StatusBarMessageRegistryMainImpl implements StatusBarMessageRegistr let extensionId = STATUS_BAR_MESSAGE_PRE; for (let counter = 0; counter < 100; counter++) { extensionId = `${STATUS_BAR_MESSAGE_PRE}_id_${('0000' + (Math.random() * Math.pow(36, 4) << 0).toString(36)).slice(-4)}`; - if (this.ids.indexOf(extensionId) === -1) { + if (!this.entries.get(extensionId)) { break; } } diff --git a/packages/plugin-ext/src/plugin/notification.ts b/packages/plugin-ext/src/plugin/notification.ts new file mode 100644 index 0000000000000..1edb6902034cf --- /dev/null +++ b/packages/plugin-ext/src/plugin/notification.ts @@ -0,0 +1,92 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { PLUGIN_RPC_CONTEXT, NotificationExt, NotificationMain } from '../api/plugin-api'; +import { CancellationToken, Progress, ProgressOptions } from '@theia/plugin'; +import { RPCProtocol } from '../api/rpc-protocol'; +import { Event, Emitter } from '@theia/core/lib/common/event'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; + +export class NotificationExtImpl implements NotificationExt { + private readonly proxy: NotificationMain; + + private readonly onCancelEmitter: Emitter = new Emitter(); + private readonly onCancel: Event = this.onCancelEmitter.event; + + constructor(rpc: RPCProtocol) { + this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.NOTIFICATION_MAIN); + } + + async withProgress( + options: ProgressOptions, + task: (progress: Progress<{ message?: string; increment?: number }>, token: CancellationToken) => PromiseLike + ): Promise { + const message = options.title ? options.title : ''; + const id = await this.proxy.$startProgress(message); + if (id) { + const token = new CancellationTokenImpl(id, this.onCancel); + const promise = await task(new ProgressCallback(id, this.proxy), token); + this.proxy.$stopProgress(id); + token.dispose(); + return promise; + } else { + throw new Error('Failed to create progress notification'); + } + } + + $onCancel(id: string): void { + this.onCancelEmitter.fire(id); + } +} + +class ProgressCallback implements Progress<{ message?: string, increment?: number }> { + + private readonly id: string | undefined; + private readonly proxy: NotificationMain; + + constructor(id: string | undefined, proxy: NotificationMain) { + this.id = id; + this.proxy = proxy; + } + report(item: { message?: string, increment?: number }) { + if (this.id) { + this.proxy.$updateProgress(this.id, item); + } + } +} + +class CancellationTokenImpl implements CancellationToken, Disposable { + + private readonly disposableCollection = new DisposableCollection(); + private readonly onCancellationRequestedEmitter: Emitter = new Emitter(); + + isCancellationRequested: boolean = false; + readonly onCancellationRequested: Event = this.onCancellationRequestedEmitter.event; + + constructor(id: string, onCancel: Event) { + this.disposableCollection.push(onCancel(cancelId => { + if (cancelId === id) { + this.onCancellationRequestedEmitter.fire(cancelId); + this.isCancellationRequested = true; + this.dispose(); + } + })); + } + + dispose(): void { + this.disposableCollection.dispose(); + } +} diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 179f1a636a33a..ebec891f58741 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -55,6 +55,9 @@ import { DiagnosticSeverity, DiagnosticTag, Location, + Progress, + ProgressOptions, + ProgressLocation, ParameterInformation, SignatureInformation, SignatureHelp, @@ -85,6 +88,8 @@ import { TerminalServiceExtImpl } from './terminal-ext'; import { LanguagesExtImpl, score } from './languages'; import { fromDocumentSelector } from './type-converters'; import { DialogsExtImpl } from './dialogs'; +import { NotificationExtImpl } from './notification'; +import { StatusBarExtImpl } from './statusBar'; import { CancellationToken } from '@theia/core/lib/common/cancellation'; import { MarkdownString } from './markdown-string'; import { TreeViewsExtImpl } from './tree/tree-views'; @@ -101,6 +106,8 @@ export function createAPIFactory( const dialogsExt = new DialogsExtImpl(rpc); const messageRegistryExt = new MessageRegistryExt(rpc); const windowStateExt = rpc.set(MAIN_RPC_CONTEXT.WINDOW_STATE_EXT, new WindowStateExtImpl()); + const notificationExt = rpc.set(MAIN_RPC_CONTEXT.NOTIFICATION_EXT, new NotificationExtImpl(rpc)); + const statusBarExt = new StatusBarExtImpl(rpc); const editorsAndDocuments = rpc.set(MAIN_RPC_CONTEXT.EDITORS_AND_DOCUMENTS_EXT, new EditorsAndDocumentsExtImpl(rpc)); const editors = rpc.set(MAIN_RPC_CONTEXT.TEXT_EDITORS_EXT, new TextEditorsExtImpl(rpc, editorsAndDocuments)); const documents = rpc.set(MAIN_RPC_CONTEXT.DOCUMENTS_EXT, new DocumentsExtImpl(rpc, editorsAndDocuments)); @@ -264,6 +271,18 @@ export function createAPIFactory( }, createTreeView(viewId: string, options: { treeDataProvider: theia.TreeDataProvider }): theia.TreeView { return treeViewsExt.createTreeView(viewId, options); + }, + withProgress( + options: ProgressOptions, + task: (progress: Progress<{ message?: string; increment?: number }>, token: theia.CancellationToken) => PromiseLike + ): PromiseLike { + switch (options.location) { + case ProgressLocation.Notification: return notificationExt.withProgress(options, task); + case ProgressLocation.Window: return statusBarExt.withProgress(options, task); + case ProgressLocation.SourceControl: return new Promise(() => { + console.error('Progress location \'SourceControl\' is not supported.'); + }); + } } }; @@ -487,6 +506,9 @@ export function createAPIFactory( Diagnostic, CompletionTriggerKind, TextEdit, + ProgressLocation, + ProgressOptions, + Progress, ParameterInformation, SignatureInformation, SignatureHelp, diff --git a/packages/plugin-ext/src/plugin/statusBar.ts b/packages/plugin-ext/src/plugin/statusBar.ts new file mode 100644 index 0000000000000..fb26ab820ed73 --- /dev/null +++ b/packages/plugin-ext/src/plugin/statusBar.ts @@ -0,0 +1,83 @@ +/******************************************************************************** + * Copyright (C) 2018 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { PLUGIN_RPC_CONTEXT, StatusBarExt, StatusBarMessageRegistryMain } from '../api/plugin-api'; +import { CancellationToken, Progress, ProgressOptions } from '@theia/plugin'; +import { RPCProtocol } from '../api/rpc-protocol'; +import { Event, Emitter } from '@theia/core/lib/common/event'; +import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; + +export class StatusBarExtImpl implements StatusBarExt { + private readonly proxy: StatusBarMessageRegistryMain; + + private readonly onCancelEmitter: Emitter = new Emitter(); + async withProgress( + options: ProgressOptions, + task: (progress: Progress<{ message?: string; increment?: number }>, token: CancellationToken) => PromiseLike + ): Promise { + const message = options.title ? '$(refresh~spin) ' + options.title : ''; + const token = new CancellationTokenImpl(this.onCancel); + const id = await this.proxy.$setMessage(message, 1, 1, undefined, undefined, undefined); + const promise = task(new ProgressCallback(id, message, this.proxy), token); + await promise; + this.proxy.$dispose(id); + token.dispose(); + return promise; + } + + private readonly onCancel: Event = this.onCancelEmitter.event; + + constructor(rpc: RPCProtocol) { + this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.STATUS_BAR_MESSAGE_REGISTRY_MAIN); + } +} + +class ProgressCallback implements Progress<{ message?: string, increment?: number }> { + + private readonly id: string; + private readonly message: string; + private readonly proxy: StatusBarMessageRegistryMain; + + constructor(id: string, message: string, proxy: StatusBarMessageRegistryMain) { + this.id = id; + this.message = message; + this.proxy = proxy; + } + report(item: { message?: string, increment?: number }) { + this.proxy.$update(this.id, this.message + (item.message ? ': ' + ' ' + item.message : '')); + } +} + +class CancellationTokenImpl implements CancellationToken, Disposable { + + private readonly disposableCollection = new DisposableCollection(); + private readonly onCancellationRequestedEmitter: Emitter = new Emitter(); + + isCancellationRequested: boolean = false; + readonly onCancellationRequested: Event = this.onCancellationRequestedEmitter.event; + + constructor(oncCancel: Event) { + this.disposableCollection.push(oncCancel(() => { + this.onCancellationRequestedEmitter.fire(undefined); + this.isCancellationRequested = true; + this.dispose(); + })); + } + + dispose(): void { + this.disposableCollection.dispose(); + } +} diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts index 3ea0e3783b92d..31e559abfa2e6 100644 --- a/packages/plugin-ext/src/plugin/types-impl.ts +++ b/packages/plugin-ext/src/plugin/types-impl.ts @@ -1230,3 +1230,49 @@ export enum FileType { Directory = 2, SymbolicLink = 64 } + +export class ProgressOptions { + /** + * The location at which progress should show. + */ + location: ProgressLocation; + /** + * A human-readable string which will be used to describe the + * operation. + */ + title?: string; + /** + * Controls if a cancel button should show to allow the user to + * cancel the long running operation. Note that currently only + * `ProgressLocation.Notification` is supporting to show a cancel + * button. + */ + cancellable?: boolean; + constructor(location: ProgressLocation, title?: string, cancellable?: boolean) { + this.location = location; + } +} +export class Progress { + /** + * Report a progress update. + * @param value A progress item, like a message and/or an + * report on how much work finished + */ + report(value: T): void { + } +} +export enum ProgressLocation { + /** + * Show progress for the source control viewlet, as overlay for the icon and as progress bar + * inside the viewlet (when visible). Neither supports cancellation nor discrete progress. + */ + SourceControl = 1, + /** + * Show progress in the status bar of the editor. Neither supports cancellation nor discrete progress. + */ + Window = 10, + /** + * Show progress as notification with an optional cancel button. Supports to show infinite and discrete progress. + */ + Notification = 15 +} diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 0e555d3a0b0dc..86fd59df3cc78 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -2750,6 +2750,77 @@ declare module '@theia/plugin' { */ export function createTreeView(viewId: string, options: { treeDataProvider: TreeDataProvider }): TreeView; + /** + * Show progress in the editor. Progress is shown while running the given callback + * and while the promise it returned isn't resolved nor rejected. The location at which + * progress should show (and other details) is defined via the passed [`ProgressOptions`](#ProgressOptions). + * + * @param task A callback returning a promise. Progress state can be reported with + * the provided [progress](#Progress)-object. + * + * To report discrete progress, use `increment` to indicate how much work has been completed. Each call with + * a `increment` value will be summed up and reflected as overall progress until 100% is reached (a value of + * e.g. `10` accounts for `10%` of work done). + * Note that currently only `ProgressLocation.Notification` is capable of showing discrete progress. + * + * To monitor if the operation has been cancelled by the user, use the provided [`CancellationToken`](#CancellationToken). + * Note that currently only `ProgressLocation.Notification` is supporting to show a cancel button to cancel the + * long running operation. + * + * @return The thenable the task-callback returned. + */ + export function withProgress(options: ProgressOptions, task: (progress: Progress<{ message?: string; increment?: number }>, token: CancellationToken) => PromiseLike): PromiseLike; + } + /** + * Value-object describing where and how progress should show. + */ + export interface ProgressOptions { + /** + * The location at which progress should show. + */ + location: ProgressLocation; + /** + * A human-readable string which will be used to describe the + * operation. + */ + title?: string; + /** + * Controls if a cancel button should show to allow the user to + * cancel the long running operation. Note that currently only + * `ProgressLocation.Notification` is supporting to show a cancel + * button. + */ + cancellable?: boolean; + } + /** + * A location in the editor at which progress information can be shown. It depends on the + * location how progress is visually represented. + */ + export enum ProgressLocation { + /** + * Show progress for the source control viewlet, as overlay for the icon and as progress bar + * inside the viewlet (when visible). Neither supports cancellation nor discrete progress. + */ + SourceControl = 1, + /** + * Show progress in the status bar of the editor. Neither supports cancellation nor discrete progress. + */ + Window = 10, + /** + * Show progress as notification with an optional cancel button. Supports to show infinite and discrete progress. + */ + Notification = 15 + } + /** + * Defines a generalized way of reporting progress updates. + */ + export interface Progress { + /** + * Report a progress update. + * @param value A progress item, like a message and/or an + * report on how much work finished + */ + report(value: T): void; } /**