// ***************************************************************************** // 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 { interfaces } from '@theia/core/shared/inversify'; import { ApplicationShell, WidgetOpenerOptions, codicon } from '@theia/core/lib/browser'; import { TerminalEditorLocationOptions } from '@theia/plugin'; import { TerminalLocation, TerminalWidget } from '@theia/terminal/lib/browser/base/terminal-widget'; import { TerminalProfileService } from '@theia/terminal/lib/browser/terminal-profile-service'; import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; import { TerminalServiceMain, TerminalServiceExt, MAIN_RPC_CONTEXT, TerminalOptions } from '../../common/plugin-api-rpc'; import { RPCProtocol } from '../../common/rpc-protocol'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { SerializableEnvironmentVariableCollection, ShellTerminalServerProxy } from '@theia/terminal/lib/common/shell-terminal-protocol'; import { TerminalLink, TerminalLinkProvider } from '@theia/terminal/lib/browser/terminal-link-provider'; import { URI } from '@theia/core/lib/common/uri'; import { PluginTerminalRegistry } from './plugin-terminal-registry'; import { CancellationToken, isObject } from '@theia/core'; import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; import { PluginSharedStyle } from './plugin-shared-style'; import { ThemeIcon } from '@theia/core/lib/common/theme'; import debounce = require('@theia/core/shared/lodash.debounce'); interface TerminalObserverData { nrOfLinesToMatch: number; outputMatcherRegex: RegExp disposables: DisposableCollection; } /** * Plugin api service allows working with terminal emulator. */ export class TerminalServiceMainImpl implements TerminalServiceMain, TerminalLinkProvider, Disposable { private readonly terminals: TerminalService; private readonly terminalProfileService: TerminalProfileService; private readonly pluginTerminalRegistry: PluginTerminalRegistry; private readonly hostedPluginSupport: HostedPluginSupport; private readonly shell: ApplicationShell; private readonly extProxy: TerminalServiceExt; private readonly sharedStyle: PluginSharedStyle; private readonly shellTerminalServer: ShellTerminalServerProxy; private readonly terminalLinkProviders: string[] = []; private readonly toDispose = new DisposableCollection(); private readonly observers = new Map(); constructor(rpc: RPCProtocol, container: interfaces.Container) { this.terminals = container.get(TerminalService); this.terminalProfileService = container.get(TerminalProfileService); this.pluginTerminalRegistry = container.get(PluginTerminalRegistry); this.hostedPluginSupport = container.get(HostedPluginSupport); this.sharedStyle = container.get(PluginSharedStyle); this.shell = container.get(ApplicationShell); this.shellTerminalServer = container.get(ShellTerminalServerProxy); this.extProxy = rpc.getProxy(MAIN_RPC_CONTEXT.TERMINAL_EXT); this.toDispose.push(this.terminals.onDidCreateTerminal(terminal => this.trackTerminal(terminal))); for (const terminal of this.terminals.all) { this.trackTerminal(terminal); } this.toDispose.push(this.terminals.onDidChangeCurrentTerminal(() => this.updateCurrentTerminal())); this.updateCurrentTerminal(); this.shellTerminalServer.getEnvVarCollections().then(collections => this.extProxy.$initEnvironmentVariableCollections(collections)); this.pluginTerminalRegistry.startCallback = id => this.startProfile(id); container.bind(TerminalLinkProvider).toDynamicValue(() => this); this.toDispose.push(this.terminalProfileService.onDidChangeDefaultShell(shell => { this.extProxy.$setShell(shell); })); } async startProfile(id: string): Promise { await this.hostedPluginSupport.activateByTerminalProfile(id); return this.extProxy.$startProfile(id, CancellationToken.None); } $setEnvironmentVariableCollection(persistent: boolean, extensionIdentifier: string, rootUri: string, collection: SerializableEnvironmentVariableCollection): void { if (collection) { this.shellTerminalServer.setCollection(extensionIdentifier, rootUri, persistent, collection, collection.description); } else { this.shellTerminalServer.deleteCollection(extensionIdentifier); } } dispose(): void { this.toDispose.dispose(); } protected updateCurrentTerminal(): void { const { currentTerminal } = this.terminals; this.extProxy.$currentTerminalChanged(currentTerminal && currentTerminal.id); } protected async trackTerminal(terminal: TerminalWidget): Promise { let name = terminal.title.label; this.extProxy.$terminalCreated(terminal.id, name); const updateTitle = () => { if (name !== terminal.title.label) { name = terminal.title.label; this.extProxy.$terminalNameChanged(terminal.id, name); } }; terminal.title.changed.connect(updateTitle); this.toDispose.push(Disposable.create(() => terminal.title.changed.disconnect(updateTitle))); const updateProcessId = () => terminal.processId.then( processId => this.extProxy.$terminalOpened(terminal.id, processId, terminal.terminalId, terminal.dimensions.cols, terminal.dimensions.rows), () => {/* no-op */ } ); updateProcessId(); this.toDispose.push(terminal.onDidOpen(() => updateProcessId())); this.toDispose.push(terminal.onTerminalDidClose(term => this.extProxy.$terminalClosed(term.id, term.exitStatus))); this.toDispose.push(terminal.onSizeChanged(({ cols, rows }) => { this.extProxy.$terminalSizeChanged(terminal.id, cols, rows); })); this.toDispose.push(terminal.onData(data => { this.extProxy.$terminalOnInput(terminal.id, data); this.extProxy.$terminalOnInteraction(terminal.id); })); this.toDispose.push(terminal.onShellTypeChanged(shellType => { this.extProxy.$terminalShellTypeChanged(terminal.id, shellType); })); this.observers.forEach((observer, id) => this.observeTerminal(id, terminal, observer)); } $write(id: string, data: string): void { const terminal = this.terminals.getById(id); if (!terminal) { return; } terminal.write(data); } $resize(id: string, cols: number, rows: number): void { const terminal = this.terminals.getById(id); if (!terminal) { return; } terminal.resize(cols, rows); } async $createTerminal(id: string, options: TerminalOptions, parentId?: string, isPseudoTerminal?: boolean): Promise { const terminal = await this.terminals.newTerminal({ id, title: options.name, iconClass: this.toIconClass(options), shellPath: options.shellPath, shellArgs: options.shellArgs, cwd: options.cwd ? new URI(options.cwd) : undefined, env: options.env, strictEnv: options.strictEnv, destroyTermOnClose: true, useServerTitle: false, attributes: options.attributes, hideFromUser: options.hideFromUser, location: this.getTerminalLocation(options, parentId), isPseudoTerminal, isTransient: options.isTransient, shellIntegrationNonce: options.shellIntegrationNonce ?? undefined }); if (options.message) { terminal.writeLine(options.message); } terminal.start(); return terminal.id; } protected getTerminalLocation(options: TerminalOptions, parentId?: string): TerminalLocation | TerminalEditorLocationOptions | { parentTerminal: string; } | undefined { if (typeof options.location === 'number' && Object.values(TerminalLocation).includes(options.location)) { return options.location; } else if (options.location && typeof options.location === 'object') { if ('parentTerminal' in options.location) { if (!parentId) { throw new Error('parentTerminal is set but no parentId is provided'); } return { 'parentTerminal': parentId }; } else { return options.location; } } return undefined; } $sendText(id: string, text: string, shouldExecute?: boolean): void { const terminal = this.terminals.getById(id); if (terminal) { text = text.replace(/\r?\n/g, '\r'); if (shouldExecute && text.charAt(text.length - 1) !== '\r') { text += '\r'; } terminal.sendText(text); } } $show(id: string, preserveFocus?: boolean): void { const terminal = this.terminals.getById(id); if (terminal) { const options: WidgetOpenerOptions = {}; if (preserveFocus) { options.mode = 'reveal'; } this.terminals.open(terminal, options); } } $hide(id: string): void { const terminal = this.terminals.getById(id); if (terminal && terminal.isVisible) { const area = this.shell.getAreaFor(terminal); if (area) { this.shell.collapsePanel(area); } } } $dispose(id: string): void { const terminal = this.terminals.getById(id); if (terminal) { terminal.dispose(); } } $setName(id: string, name: string): void { this.terminals.getById(id)?.setTitle(name); } $sendTextByTerminalId(id: number, text: string, addNewLine?: boolean): void { const terminal = this.terminals.getByTerminalId(id); if (terminal) { text = text.replace(/\r?\n/g, '\r'); if (addNewLine && text.charAt(text.length - 1) !== '\r') { text += '\r'; } terminal.sendText(text); } } $writeByTerminalId(id: number, data: string): void { const terminal = this.terminals.getByTerminalId(id); if (!terminal) { return; } terminal.write(data); } $resizeByTerminalId(id: number, cols: number, rows: number): void { const terminal = this.terminals.getByTerminalId(id); if (!terminal) { return; } terminal.resize(cols, rows); } $showByTerminalId(id: number, preserveFocus?: boolean): void { const terminal = this.terminals.getByTerminalId(id); if (terminal) { const options: WidgetOpenerOptions = {}; if (preserveFocus) { options.mode = 'reveal'; } this.terminals.open(terminal, options); } } $hideByTerminalId(id: number): void { const terminal = this.terminals.getByTerminalId(id); if (terminal && terminal.isVisible) { const area = this.shell.getAreaFor(terminal); if (area) { this.shell.collapsePanel(area); } } } $disposeByTerminalId(id: number, waitOnExit?: boolean | string): void { const terminal = this.terminals.getByTerminalId(id); if (terminal) { if (waitOnExit) { terminal.waitOnExit(waitOnExit); return; } terminal.dispose(); } } $setNameByTerminalId(id: number, name: string): void { this.terminals.getByTerminalId(id)?.setTitle(name); } async $registerTerminalLinkProvider(providerId: string): Promise { this.terminalLinkProviders.push(providerId); } async $unregisterTerminalLinkProvider(providerId: string): Promise { const index = this.terminalLinkProviders.indexOf(providerId); if (index > -1) { this.terminalLinkProviders.splice(index, 1); } } $registerTerminalObserver(id: string, nrOfLinesToMatch: number, outputMatcherRegex: string): void { const observerData = { nrOfLinesToMatch: nrOfLinesToMatch, outputMatcherRegex: new RegExp(outputMatcherRegex, 'm'), disposables: new DisposableCollection() }; this.observers.set(id, observerData); this.terminals.all.forEach(terminal => { this.observeTerminal(id, terminal, observerData); }); } protected observeTerminal(observerId: string, terminal: TerminalWidget, observerData: TerminalObserverData): void { const doMatch = debounce(() => { const lineCount = Math.min(observerData.nrOfLinesToMatch, terminal.buffer.length); const lines = terminal.buffer.getLines(terminal.buffer.length - lineCount, lineCount); const result = lines.join('\n').match(observerData.outputMatcherRegex); if (result) { this.extProxy.$reportOutputMatch(observerId, result.map(value => value)); } }); observerData.disposables.push(terminal.onOutput(output => { doMatch(); })); } protected toIconClass(options: TerminalOptions): string | ThemeIcon | undefined { const iconColor = isObject<{ id: string }>(options.color) && typeof options.color.id === 'string' ? options.color.id : undefined; let iconClass: string; if (options.iconUrl) { if (typeof options.iconUrl === 'object' && 'id' in options.iconUrl) { iconClass = codicon(options.iconUrl.id); } else { const iconReference = this.sharedStyle.toIconClass(options.iconUrl); this.toDispose.push(iconReference); iconClass = iconReference.object.iconClass; } } else { iconClass = codicon('terminal'); } return iconColor ? { id: iconClass, color: { id: iconColor } } : iconClass; } $unregisterTerminalObserver(id: string): void { const observer = this.observers.get(id); if (observer) { observer.disposables.dispose(); this.observers.delete(id); } else { throw new Error(`Unregistering unknown terminal observer: ${id}`); } } async provideLinks(line: string, terminal: TerminalWidget, cancellationToken?: CancellationToken | undefined): Promise { if (this.terminalLinkProviders.length < 1) { return []; } const links = await this.extProxy.$provideTerminalLinks(line, terminal.id, cancellationToken ?? CancellationToken.None); return links.map(link => ({ ...link, handle: () => this.extProxy.$handleTerminalLink(link) })); } }