// ***************************************************************************** // 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 // ***************************************************************************** import { injectable, inject, named } from '@theia/core/shared/inversify'; import { StatusBar } from '@theia/core/lib/browser/status-bar/status-bar'; import { StatusBarAlignment, StatusBarEntry, FrontendApplicationContribution, OnWillStopAction, codicon } from '@theia/core/lib/browser'; import { MessageService, PreferenceChange, PreferenceServiceImpl } from '@theia/core/lib/common'; import { CommandRegistry } from '@theia/core/shared/@lumino/commands'; import { Menu } from '@theia/core/shared/@lumino/widgets'; import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; import { ConnectionStatusService, ConnectionStatus } from '@theia/core/lib/browser/connection-status-service'; import { PluginDevServer } from '../common/plugin-dev-protocol'; import { HostedPluginManagerClient, HostedInstanceState, HostedPluginCommands, HostedInstanceData } from './hosted-plugin-manager-client'; import { HostedPluginLogViewer } from './hosted-plugin-log-viewer'; import { HostedPluginPreferences } from '../common/hosted-plugin-preferences'; import { nls } from '@theia/core/lib/common/nls'; import { ILogger } from '@theia/core'; /** * 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(PluginDevServer) protected readonly hostedPluginServer: PluginDevServer; @inject(HostedPluginManagerClient) protected readonly hostedPluginManagerClient: HostedPluginManagerClient; @inject(ConnectionStatusService) protected readonly connectionStatusService: ConnectionStatusService; @inject(HostedPluginLogViewer) protected readonly hostedPluginLogViewer: HostedPluginLogViewer; @inject(HostedPluginPreferences) protected readonly hostedPluginPreferences: HostedPluginPreferences; @inject(PreferenceServiceImpl) protected readonly preferenceService: PreferenceServiceImpl; @inject(MessageService) protected readonly messageService: MessageService; @inject(ILogger) @named('plugin-dev:HostedPluginController') protected readonly logger: ILogger; private pluginState: HostedInstanceState = HostedInstanceState.STOPPED; // used only for displaying Running instead of Watching in status bar if run of watcher fails private watcherSuccess: boolean; private entry: StatusBarEntry | undefined; onWillStop(): OnWillStopAction | undefined { if (this.pluginState === HostedInstanceState.RUNNING || this.pluginState === HostedInstanceState.STARTING) { return { reason: 'Hosted plugin instance is still running', action: async () => { try { await this.hostedPluginManagerClient.stop(false); } catch { // Best effort — don't block shutdown if termination fails } return true; } }; } return undefined; } public initialize(): void { this.hostedPluginServer.getHostedPlugin().then(pluginMetadata => { if (!pluginMetadata) { this.frontendApplicationStateService.reachedState('ready').then(() => { // handles status bar item this.hostedPluginManagerClient.onStateChanged(e => { if (e.state === HostedInstanceState.STARTING) { this.onHostedPluginStarting(); } else if (e.state === HostedInstanceState.RUNNING) { this.onHostedPluginRunning(); } else if (e.state === HostedInstanceState.STOPPED) { this.onHostedPluginStopped(); } else if (e.state === HostedInstanceState.FAILED) { this.onHostedPluginFailed(); } }); // handles watch compilation this.hostedPluginManagerClient.onStateChanged(e => this.handleWatchers(e)); // updates status bar if page is loading when hosted instance is already running this.hostedPluginServer.isHostedPluginInstanceRunning().then(running => { if (running) { this.onHostedPluginRunning(); } }); }); this.connectionStatusService.onStatusChange(() => this.onConnectionStatusChanged()); this.preferenceService.onPreferenceChanged(preference => this.onPreferencesChanged(preference)); } else { this.logger.error(`Need to load plugin ${pluginMetadata.model.id}`); } }); } /** * Display status bar element for stopped plugin. */ protected async onHostedPluginStopped(): Promise { this.pluginState = HostedInstanceState.STOPPED; this.entry = { text: `${nls.localize('theia/plugin-dev/hostedPluginStopped', '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 = HostedInstanceState.STARTING; this.hostedPluginLogViewer.showLogConsole(); this.entry = { text: `$(cog~spin) ${nls.localize('theia/plugin-dev/hostedPluginStarting', '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 = HostedInstanceState.RUNNING; let entryText: string; if (this.hostedPluginPreferences['hosted-plugin.watchMode'] && this.watcherSuccess) { entryText = `$(cog~spin) ${nls.localize('theia/plugin-dev/hostedPluginWatching', 'Hosted Plugin: Watching')}$(angle-up)`; } else { entryText = `$(cog~spin) ${nls.localize('theia/plugin-dev/hostedPluginRunning', 'Hosted Plugin: Running')} $(angle-up)`; } this.entry = { text: entryText, 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 = HostedInstanceState.FAILED; this.entry = { text: `${nls.localize('theia/plugin-dev/hostedPluginStopped', '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); } protected async onPreferencesChanged(preference: PreferenceChange): Promise { if (preference.preferenceName === 'hosted-plugin.watchMode') { if (await this.hostedPluginServer.isHostedPluginInstanceRunning()) { const pluginLocation = await this.hostedPluginServer.getHostedPluginURI(); const isWatchCompilationRunning = await this.hostedPluginServer.isWatchCompilationRunning(pluginLocation); if (this.hostedPluginPreferences['hosted-plugin.watchMode']) { if (!isWatchCompilationRunning) { await this.runWatchCompilation(pluginLocation.toString()); } } else { if (isWatchCompilationRunning) { await this.hostedPluginServer.stopWatchCompilation(pluginLocation.toString()); } } // update status bar this.onHostedPluginRunning(); } } } /** * Starts / stops watchers on hosted instance state change. * * @param event hosted instance state change event */ protected async handleWatchers(event: HostedInstanceData): Promise { if (event.state === HostedInstanceState.RUNNING) { if (this.hostedPluginPreferences['hosted-plugin.watchMode']) { await this.runWatchCompilation(event.pluginLocation.toString()); // update status bar this.onHostedPluginRunning(); } } else if (event.state === HostedInstanceState.STOPPING) { if (this.hostedPluginPreferences['hosted-plugin.watchMode']) { const isRunning = await this.hostedPluginServer.isWatchCompilationRunning(event.pluginLocation.toString()); if (isRunning) { try { await this.hostedPluginServer.stopWatchCompilation(event.pluginLocation.toString()); } catch (error) { this.messageService.error(this.getErrorMessage(error)); } } } } } private async runWatchCompilation(pluginLocation: string): Promise { try { await this.hostedPluginServer.runWatchCompilation(pluginLocation); this.watcherSuccess = true; } catch (error) { this.messageService.error(this.getErrorMessage(error)); this.watcherSuccess = false; } } // eslint-disable-next-line @typescript-eslint/no-explicit-any private getErrorMessage(error: any): string { return error?.message?.substring(error.message.indexOf(':') + 1) || ''; } /** * Updating status bar element when changing connection status. */ private onConnectionStatusChanged(): void { if (this.connectionStatusService.currentStatus === ConnectionStatus.OFFLINE) { // Re-set the element only if it's visible on status bar if (this.entry) { const offlineElement = { text: nls.localize('theia/plugin-dev/hostedPluginStopped', '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.isHostedPluginInstanceRunning().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 === HostedInstanceState.RUNNING) { this.addCommandsForRunningPlugin(commands, menu); } else if (this.pluginState === HostedInstanceState.STOPPED || this.pluginState === HostedInstanceState.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: nls.localize('theia/plugin-dev/stopInstance', 'Stop Instance'), iconClass: codicon('debug-stop'), execute: () => setTimeout(() => this.hostedPluginManagerClient.stop(), 100) }); menu.addItem({ type: 'command', command: HostedPluginCommands.STOP.id }); commands.addCommand(HostedPluginCommands.RESTART.id, { label: nls.localize('theia/plugin-dev/restartInstance', 'Restart Instance'), iconClass: codicon('debug-restart'), 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: nls.localize('theia/plugin-dev/startInstance', 'Start Instance'), iconClass: codicon('play'), execute: () => setTimeout(() => this.hostedPluginManagerClient.start(), 100) }); menu.addItem({ type: 'command', command: HostedPluginCommands.START.id }); commands.addCommand(HostedPluginCommands.DEBUG.id, { label: nls.localize('theia/plugin-dev/debugInstance', 'Debug Instance'), iconClass: codicon('debug'), execute: () => setTimeout(() => this.hostedPluginManagerClient.debug(), 100) }); menu.addItem({ type: 'command', command: HostedPluginCommands.DEBUG.id }); } }