diff --git a/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts b/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts new file mode 100644 index 0000000000000..17e40ffae4dde --- /dev/null +++ b/packages/plugin-ext/src/hosted/browser/hosted-plugin-informer.ts @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2018 Red Hat, Inc. and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { injectable, inject } from 'inversify'; +import { StatusBar } from '@theia/core/lib/browser/status-bar/status-bar'; +import { StatusBarAlignment, StatusBarEntry, FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { HostedPluginServer } from '../../common/plugin-protocol'; +import { ConnectionStatusService, ConnectionState } from '@theia/core/lib/browser/connection-status-service'; +import URI from '@theia/core/lib/common/uri'; +import { FileStat } from '@theia/filesystem/lib/common'; +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; + +/** + * Informs the user whether Theia is running with hosted plugin. + * Adds 'Development Host' status bar element and appends the same prefix to window title. + */ +@injectable() +export class HostedPluginInformer implements FrontendApplicationContribution { + + public static readonly DEVELOPMENT_HOST_TITLE = "Development Host"; + + public static readonly DEVELOPMENT_HOST = "development-host"; + + public static readonly DEVELOPMENT_HOST_OFFLINE = "development-host-offline"; + + private entry: StatusBarEntry; + + @inject(StatusBar) + protected readonly statusBar: StatusBar; + + @inject(WorkspaceService) + protected readonly workspaceService: WorkspaceService; + + @inject(HostedPluginServer) + protected readonly hostedPluginServer: HostedPluginServer; + + @inject(ConnectionStatusService) + protected readonly connectionStatusService: ConnectionStatusService; + + @inject(FrontendApplicationStateService) + protected readonly frontendApplicationStateService: FrontendApplicationStateService; + + public initialize(): void { + this.workspaceService.root.then(root => { + this.hostedPluginServer.getHostedPlugin().then(pluginMetadata => { + if (pluginMetadata) { + this.updateTitle(root); + + this.entry = { + text: `$(cube) ${HostedPluginInformer.DEVELOPMENT_HOST_TITLE}`, + tooltip: `Hosted Plugin '${pluginMetadata.model.name}'`, + alignment: StatusBarAlignment.LEFT, + priority: 100 + }; + + this.frontendApplicationStateService.reachedState('ready').then(() => { + this.updateStatusBarElement(); + }); + + this.connectionStatusService.onStatusChange(() => this.updateStatusBarElement()); + } + }); + }); + } + + private updateStatusBarElement(): void { + if (this.connectionStatusService.currentState.state === ConnectionState.OFFLINE) { + this.entry.className = HostedPluginInformer.DEVELOPMENT_HOST_OFFLINE; + } else { + this.entry.className = HostedPluginInformer.DEVELOPMENT_HOST; + } + + this.statusBar.setElement(HostedPluginInformer.DEVELOPMENT_HOST, this.entry); + } + + private updateTitle(root: FileStat | undefined): void { + if (root) { + const uri = new URI(root.uri); + document.title = HostedPluginInformer.DEVELOPMENT_HOST_TITLE + " - " + uri.displayName; + } else { + document.title = HostedPluginInformer.DEVELOPMENT_HOST_TITLE; + } + } + +} diff --git a/packages/plugin-ext/src/main/browser/hosted-plugin-controller.ts b/packages/plugin-ext/src/main/browser/hosted-plugin-controller.ts new file mode 100644 index 0000000000000..7ddac6c68d96f --- /dev/null +++ b/packages/plugin-ext/src/main/browser/hosted-plugin-controller.ts @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2018 Red Hat, Inc. and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +import { injectable, inject } from 'inversify'; +import { StatusBar } from '@theia/core/lib/browser/status-bar/status-bar'; +import { StatusBarAlignment, StatusBarEntry, FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { HostedPluginServer } from '../../common/plugin-protocol'; +import { HostedPluginManagerClient, HostedPluginState, HostedPluginCommands } from './plugin-manager-client'; +import { CommandRegistry } from '@phosphor/commands'; +import { Menu } from '@phosphor/widgets'; +import { setTimeout } from 'timers'; +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; +import { ConnectionStatusService, ConnectionState } from '@theia/core/lib/browser/connection-status-service'; + +/** + * Adds a status bar element displaying the state of secondary Theia instance with hosted plugin and + * allows controlling the instance by simple clicking on the status bar element. + */ +@injectable() +export class HostedPluginController implements FrontendApplicationContribution { + + public static readonly HOSTED_PLUGIN = "hosted-plugin"; + public static readonly HOSTED_PLUGIN_OFFLINE = "hosted-plugin-offline"; + public static readonly HOSTED_PLUGIN_FAILED = "hosted-plugin-failed"; + + @inject(StatusBar) + protected readonly statusBar: StatusBar; + + @inject(FrontendApplicationStateService) + protected readonly frontendApplicationStateService: FrontendApplicationStateService; + + @inject(HostedPluginServer) + protected readonly hostedPluginServer: HostedPluginServer; + + @inject(HostedPluginManagerClient) + protected readonly hostedPluginManagerClient: HostedPluginManagerClient; + + @inject(ConnectionStatusService) + protected readonly connectionStatusService: ConnectionStatusService; + + private pluginState: HostedPluginState = HostedPluginState.Stopped; + + private entry: StatusBarEntry | undefined; + + public initialize(): void { + this.hostedPluginServer.getHostedPlugin().then(pluginMetadata => { + if (!pluginMetadata) { + this.frontendApplicationStateService.reachedState('ready').then(() => { + this.hostedPluginManagerClient.onStateChanged(e => { + if (e === 'starting') { + this.onHostedPluginStarting(); + } else if (e === 'running') { + this.onHostedPluginRunning(); + } else if (e === 'stopped') { + this.onHostedPluginStopped(); + } else if (e === 'failed') { + this.onHostedPluginFailed(); + } + }); + + this.hostedPluginServer.isHostedTheiaRunning().then((running) => { + if (running) { + this.onHostedPluginRunning(); + } + }); + }); + + this.connectionStatusService.onStatusChange(() => this.onConnectionStatusChanged()); + } + }); + } + + /** + * Display status bar element for stopped plugin. + */ + protected async onHostedPluginStopped(): Promise { + this.pluginState = HostedPluginState.Stopped; + + this.entry = { + text: `Hosted Plugin: Stopped $(angle-up)`, + alignment: StatusBarAlignment.LEFT, + priority: 100, + onclick: e => { + this.showMenu(e.clientX, e.clientY); + } + }; + + this.entry.className = HostedPluginController.HOSTED_PLUGIN; + await this.statusBar.setElement(HostedPluginController.HOSTED_PLUGIN, this.entry); + } + + /** + * Display status bar element for starting plugin. + */ + protected async onHostedPluginStarting(): Promise { + this.pluginState = HostedPluginState.Starting; + + this.entry = { + text: `$(cog~spin) Hosted Plugin: Starting`, + alignment: StatusBarAlignment.LEFT, + priority: 100 + }; + + this.entry.className = HostedPluginController.HOSTED_PLUGIN; + await this.statusBar.setElement(HostedPluginController.HOSTED_PLUGIN, this.entry); + } + + /** + * Display status bar element for running plugin. + */ + protected async onHostedPluginRunning(): Promise { + this.pluginState = HostedPluginState.Running; + + this.entry = { + text: `$(cog~spin) Hosted Plugin: Running $(angle-up)`, + alignment: StatusBarAlignment.LEFT, + priority: 100, + onclick: e => { + this.showMenu(e.clientX, e.clientY); + } + }; + + this.entry.className = HostedPluginController.HOSTED_PLUGIN; + await this.statusBar.setElement(HostedPluginController.HOSTED_PLUGIN, this.entry); + } + + /** + * Display status bar element for failed plugin. + */ + protected async onHostedPluginFailed(): Promise { + this.pluginState = HostedPluginState.Failed; + + this.entry = { + text: `Hosted Plugin: Stopped $(angle-up)`, + alignment: StatusBarAlignment.LEFT, + priority: 100, + onclick: e => { + this.showMenu(e.clientX, e.clientY); + } + }; + + this.entry.className = HostedPluginController.HOSTED_PLUGIN_FAILED; + await this.statusBar.setElement(HostedPluginController.HOSTED_PLUGIN, this.entry); + } + + /** + * Updaing status bar element when changing connection status. + */ + private onConnectionStatusChanged(): void { + if (this.connectionStatusService.currentState.state === ConnectionState.OFFLINE) { + // Re-set the element only if it's visible on status bar + if (this.entry) { + const offlineElement = { + text: `Hosted Plugin: Stopped`, + alignment: StatusBarAlignment.LEFT, + priority: 100 + }; + + this.entry.className = HostedPluginController.HOSTED_PLUGIN_OFFLINE; + this.statusBar.setElement(HostedPluginController.HOSTED_PLUGIN, offlineElement); + } + } else { + // ask state of hosted plugin when switching to Online + if (this.entry) { + this.hostedPluginServer.isHostedTheiaRunning().then((running) => { + if (running) { + this.onHostedPluginRunning(); + } else { + this.onHostedPluginStopped(); + } + }); + } + } + } + + /** + * Show menu containing actions to start/stop/restart hosted plugin. + */ + protected showMenu(x: number, y: number): void { + const commands = new CommandRegistry(); + const menu = new Menu({ + commands + }); + + if (this.pluginState === 'running') { + this.addCommandsForRunningPlugin(commands, menu); + } else if (this.pluginState === 'stopped' || this.pluginState === 'failed') { + this.addCommandsForStoppedPlugin(commands, menu); + } + + menu.open(x, y); + } + + /** + * Adds commands to the menu for running plugin. + */ + protected addCommandsForRunningPlugin(commands: CommandRegistry, menu: Menu): void { + commands.addCommand(HostedPluginCommands.STOP.id, { + label: 'Stop Instance', + icon: 'fa fa-stop', + execute: () => setTimeout(() => this.hostedPluginManagerClient.stop(), 100) + }); + + menu.addItem({ + type: 'command', + command: HostedPluginCommands.STOP.id + }); + + commands.addCommand(HostedPluginCommands.RESTART.id, { + label: 'Restart Instance', + icon: 'fa fa-repeat', + execute: () => setTimeout(() => this.hostedPluginManagerClient.restart(), 100) + }); + + menu.addItem({ + type: 'command', + command: HostedPluginCommands.RESTART.id + }); + } + + /** + * Adds command to the menu for stopped plugin. + */ + protected addCommandsForStoppedPlugin(commands: CommandRegistry, menu: Menu): void { + commands.addCommand(HostedPluginCommands.START.id, { + label: "Start Instance", + icon: 'fa fa-play', + execute: () => setTimeout(() => this.hostedPluginManagerClient.start(), 100) + }); + + menu.addItem({ + type: 'command', + command: HostedPluginCommands.START.id + }); + } + +} diff --git a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts index 49cbe2538b812..bcd260b68a938 100644 --- a/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts +++ b/packages/plugin-ext/src/main/browser/plugin-ext-frontend-module.ts @@ -4,6 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 */ + +import '../../../src/main/style/status-bar.css'; + import { ContainerModule } from "inversify"; import { FrontendApplicationContribution, FrontendApplication, WidgetFactory, KeybindingContribution } from "@theia/core/lib/browser"; import { MaybePromise, CommandContribution, MenuContribution } from "@theia/core/lib/common"; @@ -19,6 +22,9 @@ import { ModalNotification } from './dialogs/modal-notification'; import { PluginWidget } from "./plugin-ext-widget"; import { PluginFrontendViewContribution } from "./plugin-frontend-view-contribution"; +import { HostedPluginInformer } from "../../hosted/browser/hosted-plugin-informer"; +import { HostedPluginController } from "./hosted-plugin-controller"; + import '../../../src/main/browser/style/index.css'; import { PluginExtDeployCommandService } from "./plugin-ext-deploy-command"; @@ -30,6 +36,9 @@ export default new ContainerModule(bind => { bind(HostedPluginWatcher).toSelf().inSingletonScope(); bind(HostedPluginManagerClient).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).to(HostedPluginInformer).inSingletonScope(); + bind(FrontendApplicationContribution).to(HostedPluginController).inSingletonScope(); + bind(PluginApiFrontendContribution).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toDynamicValue(c => c.container.get(PluginApiFrontendContribution)); bind(CommandContribution).toDynamicValue(c => c.container.get(PluginApiFrontendContribution)); diff --git a/packages/plugin-ext/src/main/browser/plugin-frontend-contribution.ts b/packages/plugin-ext/src/main/browser/plugin-frontend-contribution.ts index 502e3bb94598c..56cc4e2b8c55c 100644 --- a/packages/plugin-ext/src/main/browser/plugin-frontend-contribution.ts +++ b/packages/plugin-ext/src/main/browser/plugin-frontend-contribution.ts @@ -20,16 +20,16 @@ export class PluginApiFrontendContribution implements CommandContribution { protected readonly pluginExtDeployCommandService: PluginExtDeployCommandService; registerCommands(commands: CommandRegistry): void { - commands.registerCommand(HostedPluginCommands.RUN, { + commands.registerCommand(HostedPluginCommands.START, { execute: () => this.hostedPluginManagerClient.start() }); - commands.registerCommand(HostedPluginCommands.TERMINATE, { + commands.registerCommand(HostedPluginCommands.STOP, { execute: () => this.hostedPluginManagerClient.stop() }); commands.registerCommand(HostedPluginCommands.RESTART, { execute: () => this.hostedPluginManagerClient.restart() }); - commands.registerCommand(HostedPluginCommands.SELECT_PLUGIN_PATH, { + commands.registerCommand(HostedPluginCommands.SELECT_PATH, { execute: () => this.hostedPluginManagerClient.selectPluginPath() }); diff --git a/packages/plugin-ext/src/main/browser/plugin-manager-client.ts b/packages/plugin-ext/src/main/browser/plugin-manager-client.ts index 2a0efed4d7338..0a43fbb0afdb2 100644 --- a/packages/plugin-ext/src/main/browser/plugin-manager-client.ts +++ b/packages/plugin-ext/src/main/browser/plugin-manager-client.ts @@ -7,7 +7,7 @@ import { injectable, inject } from 'inversify'; import URI from '@theia/core/lib/common/uri'; -import { MessageService, Command } from '@theia/core/lib/common'; +import { MessageService, Command, Emitter, Event } from '@theia/core/lib/common'; import { LabelProvider, isNative } from '@theia/core/lib/browser'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; import { WorkspaceService } from '@theia/workspace/lib/browser'; @@ -15,6 +15,39 @@ import { FileSystem } from '@theia/filesystem/lib/common'; import { FileDialogFactory, DirNode } from '@theia/filesystem/lib/browser'; import { HostedPluginServer } from '../../common/plugin-protocol'; +/** + * Commands to control Hosted plugin instances. + */ +export namespace HostedPluginCommands { + export const START: Command = { + id: 'hosted-plugin:start', + label: 'Hosted Plugin: Start Instance' + }; + export const STOP: Command = { + id: 'hosted-plugin:stop', + label: 'Hosted Plugin: Stop Instance' + }; + export const RESTART: Command = { + id: 'hosted-plugin:restart', + label: 'Hosted Plugin: Restart Instance' + }; + export const SELECT_PATH: Command = { + id: 'hosted-plugin:select-path', + label: 'Hosted Plugin: Select Path' + }; +} + +/** + * Available states of hosted plugin instance. + */ +export enum HostedPluginState { + Stopped = 'stopped', + Starting = 'starting', + Running = 'running', + Stopping = 'stopping', + Failed = 'failed' +} + /** * Responsible for UI to set up and control Hosted Plugin Instance. */ @@ -35,8 +68,17 @@ export class HostedPluginManagerClient { @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + // path to the plugin on the file system protected pluginLocation: URI | undefined; - protected pluginInstanceUri: string | undefined; + + // URL to the running plugin instance + protected pluginInstanceURL: string | undefined; + + protected readonly stateChanged = new Emitter(); + + get onStateChanged(): Event { + return this.stateChanged.event; + } async start(): Promise { if (!this.pluginLocation) { @@ -46,19 +88,28 @@ export class HostedPluginManagerClient { return; } } + try { + this.stateChanged.fire(HostedPluginState.Starting); this.messageService.info('Starting hosted instance server ...'); - await this.doRunRequest(this.pluginLocation); - this.messageService.info('Hosted instance is running at: ' + this.pluginInstanceUri); + + this.pluginInstanceURL = await this.hostedPluginServer.runHostedPluginInstance(this.pluginLocation.toString()); + await this.openPluginWindow(); + + this.messageService.info('Hosted instance is running at: ' + this.pluginInstanceURL); + this.stateChanged.fire(HostedPluginState.Running); } catch (error) { this.messageService.error('Failed to run hosted plugin instance: ' + this.getErrorMessage(error)); + this.stateChanged.fire(HostedPluginState.Failed); } } async stop(): Promise { try { + this.stateChanged.fire(HostedPluginState.Stopping); await this.hostedPluginServer.terminateHostedPluginInstance(); - this.messageService.info((this.pluginInstanceUri ? this.pluginInstanceUri : 'The instance') + ' has been terminated.'); + this.messageService.info((this.pluginInstanceURL ? this.pluginInstanceURL : 'The instance') + ' has been terminated.'); + this.stateChanged.fire(HostedPluginState.Stopped); } catch (error) { this.messageService.warn(this.getErrorMessage(error)); } @@ -69,13 +120,17 @@ export class HostedPluginManagerClient { await this.stop(); this.messageService.info('Starting hosted instance server ...'); + // It takes some time before OS released all resources e.g. port. // Keeping tries to run hosted instance with delay. + this.stateChanged.fire(HostedPluginState.Starting); let lastError; for (let tries = 0; tries < 15; tries++) { try { - await this.doRunRequest(this.pluginLocation!); - this.messageService.info('Hosted instance is running at: ' + this.pluginInstanceUri); + this.pluginInstanceURL = await this.hostedPluginServer.runHostedPluginInstance(this.pluginLocation!.toString()); + await this.openPluginWindow(); + this.messageService.info('Hosted instance is running at: ' + this.pluginInstanceURL); + this.stateChanged.fire(HostedPluginState.Running); return; } catch (error) { lastError = error; @@ -86,6 +141,8 @@ export class HostedPluginManagerClient { } else { this.messageService.warn('Hosted Plugin instance is not running.'); } + + this.stateChanged.fire(HostedPluginState.Failed); } /** @@ -105,7 +162,7 @@ export class HostedPluginManagerClient { const name = this.labelProvider.getName(rootUri); const label = await this.labelProvider.getIcon(root); const rootNode = DirNode.createRoot(rootStat, name, label); - const dialog = this.fileDialogFactory({ title: HostedPluginCommands.SELECT_PLUGIN_PATH.label! }); + const dialog = this.fileDialogFactory({ title: HostedPluginCommands.SELECT_PATH.label! }); dialog.model.navigateTo(rootNode); const node = await dialog.open(); if (node) { @@ -119,20 +176,19 @@ export class HostedPluginManagerClient { } /** - * Send run command to backend. Throws an error if start failed. - * Sets hosted instance uri into pluginInstanceUri field. - * - * @param pluginLocation uri to plugin binaries + * Opens window with URL to the running plugin instance. */ - protected async doRunRequest(pluginLocation: URI): Promise { - const uri = await this.hostedPluginServer.runHostedPluginInstance(pluginLocation.toString()); - this.pluginInstanceUri = uri; - if (!isNative) { - // Open a new tab in case of browser + protected async openPluginWindow(): Promise { + // do nothing for electron browser + if (isNative) { + return; + } + + if (this.pluginInstanceURL) { try { - this.windowService.openNewWindow(uri); + this.windowService.openNewWindow(this.pluginInstanceURL); } catch (err) { - this.messageService.warn('Your browser prevented opening of new tab. You can do it manually: ' + uri); + this.messageService.warn('Your browser prevented opening of new tab. You can do it manually: ' + this.pluginInstanceURL); } } } @@ -141,22 +197,3 @@ export class HostedPluginManagerClient { return error.message.substring(error.message.indexOf(':') + 1); } } - -export namespace HostedPluginCommands { - export const RUN: Command = { - id: 'hosted-plugin:run', - label: 'Hosted Plugin: Start Instance' - }; - export const TERMINATE: Command = { - id: 'hosted-plugin:terminate', - label: 'Hosted Plugin: Stop Instance' - }; - export const RESTART: Command = { - id: 'hosted-plugin:restart', - label: 'Hosted Plugin: Restart Instance' - }; - export const SELECT_PLUGIN_PATH: Command = { - id: 'hosted-plugin:select-path', - label: 'Hosted Plugin: Select Path' - }; -} diff --git a/packages/plugin-ext/src/main/style/status-bar.css b/packages/plugin-ext/src/main/style/status-bar.css new file mode 100644 index 0000000000000..bcffd6ac8b1dd --- /dev/null +++ b/packages/plugin-ext/src/main/style/status-bar.css @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2018 Red Hat, Inc. and others. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + */ + +#theia-statusBar .development-host { + background-color: var(--theia-success-color0); +} + +#theia-statusBar .development-host-offline { + background-color: var(--theia-error-color0); +} + +#theia-statusBar .hosted-plugin { + background-color: var(--theia-info-color0); +} + +#theia-statusBar .hosted-plugin:hover { + background-color: var(--theia-info-color1); +} + +#theia-statusBar .hosted-plugin-failed { + background-color: var(--theia-error-color0); +}