diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 5505515e9b616..bf40c9d1e7c3f 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -301,6 +301,7 @@ export interface TerminalServiceExt { $handleTerminalLink(link: ProvidedTerminalLink): Promise; getEnvironmentVariableCollection(extensionIdentifier: string): theia.GlobalEnvironmentVariableCollection; $setShell(shell: string): void; + $reportOutputMatch(observerId: string, groups: string[]): void; } export interface OutputChannelRegistryExt { createOutputChannel(name: string, pluginInfo: PluginInfo): theia.OutputChannel, @@ -438,6 +439,20 @@ export interface TerminalServiceMain { * @param providerId id of the terminal link provider to be unregistered. */ $unregisterTerminalLinkProvider(providerId: string): Promise; + + /** + * Register a new terminal observer. + * @param providerId id of the terminal link provider to be registered. + * @param nrOfLinesToMatch the number of lines to match the outputMatcherRegex against + * @param outputMatcherRegex the regex to match the output to + */ + $registerTerminalObserver(id: string, nrOfLinesToMatch: number, outputMatcherRegex: string): unknown; + + /** + * Unregister the terminal observer with the specified id. + * @param providerId id of the terminal observer to be unregistered. + */ + $unregisterTerminalObserver(id: string): unknown; } export interface AutoFocus { diff --git a/packages/plugin-ext/src/main/browser/terminal-main.ts b/packages/plugin-ext/src/main/browser/terminal-main.ts index 590955fc27faa..059b4d9394be8 100644 --- a/packages/plugin-ext/src/main/browser/terminal-main.ts +++ b/packages/plugin-ext/src/main/browser/terminal-main.ts @@ -30,6 +30,13 @@ import { getIconClass } from '../../plugin/terminal-ext'; import { PluginTerminalRegistry } from './plugin-terminal-registry'; import { CancellationToken } from '@theia/core'; import { HostedPluginSupport } from '../../hosted/browser/hosted-plugin'; +import debounce = require('@theia/core/shared/lodash.debounce'); + +interface TerminalObserverData { + nrOfLinesToMatch: number; + outputMatcherRegex: RegExp + disposables: DisposableCollection; +} /** * Plugin api service allows working with terminal emulator. @@ -46,6 +53,7 @@ export class TerminalServiceMainImpl implements TerminalServiceMain, TerminalLin 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); @@ -121,6 +129,8 @@ export class TerminalServiceMainImpl implements TerminalServiceMain, TerminalLin this.extProxy.$terminalOnInput(terminal.id, data); this.extProxy.$terminalStateChanged(terminal.id); })); + + this.observers.forEach((observer, id) => this.observeTerminal(id, terminal, observer)); } $write(id: string, data: string): void { @@ -293,6 +303,42 @@ export class TerminalServiceMainImpl implements TerminalServiceMain, TerminalLin } } + $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(); + })); + } + + $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 []; diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index fb2949e261f5b..1114cc2e7244c 100644 --- a/packages/plugin-ext/src/plugin/plugin-context.ts +++ b/packages/plugin-ext/src/plugin/plugin-context.ts @@ -603,6 +603,12 @@ export function createAPIFactory( registerTerminalQuickFixProvider(id: string, provider: theia.TerminalQuickFixProvider): theia.Disposable { return terminalExt.registerTerminalQuickFixProvider(id, provider); }, + + /** Theia-specific TerminalObserver */ + registerTerminalObserver(observer: theia.TerminalObserver): theia.Disposable { + return terminalExt.registerTerminalObserver(observer); + }, + /** @stubbed ShareProvider */ registerShareProvider: () => Disposable.NULL, }; diff --git a/packages/plugin-ext/src/plugin/terminal-ext.ts b/packages/plugin-ext/src/plugin/terminal-ext.ts index d4833476c9186..d23967b13ca77 100644 --- a/packages/plugin-ext/src/plugin/terminal-ext.ts +++ b/packages/plugin-ext/src/plugin/terminal-ext.ts @@ -49,7 +49,6 @@ export function getIconClass(options: theia.TerminalOptions | theia.ExtensionTer */ @injectable() export class TerminalServiceExtImpl implements TerminalServiceExt { - private readonly proxy: TerminalServiceMain; private readonly _terminals = new Map(); @@ -58,6 +57,7 @@ export class TerminalServiceExtImpl implements TerminalServiceExt { private static nextProviderId = 0; private readonly terminalLinkProviders = new Map(); + private readonly terminalObservers = new Map(); private readonly terminalProfileProviders = new Map(); private readonly onDidCloseTerminalEmitter = new Emitter(); readonly onDidCloseTerminal: theia.Event = this.onDidCloseTerminalEmitter.event; @@ -270,6 +270,25 @@ export class TerminalServiceExtImpl implements TerminalServiceExt { return Disposable.NULL; } + registerTerminalObserver(observer: theia.TerminalObserver): theia.Disposable { + const id = (TerminalServiceExtImpl.nextProviderId++).toString(); + this.terminalObservers.set(id, observer); + this.proxy.$registerTerminalObserver(id, observer.nrOfLinesToMatch, observer.outputMatcherRegex); + return Disposable.create(() => { + this.proxy.$unregisterTerminalObserver(id); + this.terminalObservers.delete(id); + }); + } + + $reportOutputMatch(observerId: string, groups: string[]): void { + const observer = this.terminalObservers.get(observerId); + if (observer) { + observer.matchOccurred(groups); + } else { + throw new Error(`reporting matches for unregistered observer: ${observerId} `); + } + } + protected isExtensionTerminalOptions(options: theia.TerminalOptions | theia.ExtensionTerminalOptions): options is theia.ExtensionTerminalOptions { return 'pty' in options; } diff --git a/packages/plugin/src/theia-extra.d.ts b/packages/plugin/src/theia-extra.d.ts index f1826c780dd75..66a30583d9428 100644 --- a/packages/plugin/src/theia-extra.d.ts +++ b/packages/plugin/src/theia-extra.d.ts @@ -363,6 +363,26 @@ export module '@theia/plugin' { color?: ThemeColor; } + export interface TerminalObserver { + + /** + * A regex to match against the latest terminal output. + */ + readonly outputMatcherRegex: string; + /** + * The maximum number of lines to match the regex against. Maximum is 40 lines. + */ + readonly nrOfLinesToMatch: number; + /** + * Invoked when the regex matched against the terminal contents. + * @param groups The matched groups + */ + matchOccurred(groups: string[]): void; + } + + export namespace window { + export function registerTerminalObserver(observer: TerminalObserver): Disposable; + } } /** diff --git a/packages/terminal/src/browser/base/terminal-widget.ts b/packages/terminal/src/browser/base/terminal-widget.ts index 9350d0fb32dc7..8ccb29477e2dc 100644 --- a/packages/terminal/src/browser/base/terminal-widget.ts +++ b/packages/terminal/src/browser/base/terminal-widget.ts @@ -48,6 +48,15 @@ export interface TerminalSplitLocation { readonly parentTerminal: string; } +export interface TerminalBuffer { + readonly length: number; + /** + * @param start zero based index of the first line to return + * @param length the max number or lines to return + */ + getLines(start: number, length: number): string[]; +} + /** * Terminal UI widget. */ @@ -118,6 +127,10 @@ export abstract class TerminalWidget extends BaseWidget { /** Event that fires when the terminal input data */ abstract onData: Event; + abstract onOutput: Event; + + abstract buffer: TerminalBuffer; + abstract scrollLineUp(): void; abstract scrollLineDown(): void; diff --git a/packages/terminal/src/browser/terminal-widget-impl.ts b/packages/terminal/src/browser/terminal-widget-impl.ts index 17992cbdb5f85..42924cfdefc93 100644 --- a/packages/terminal/src/browser/terminal-widget-impl.ts +++ b/packages/terminal/src/browser/terminal-widget-impl.ts @@ -29,7 +29,8 @@ import { IBaseTerminalServer, TerminalProcessInfo, TerminalExitReason } from '.. import { TerminalWatcher } from '../common/terminal-watcher'; import { TerminalWidgetOptions, TerminalWidget, TerminalDimensions, TerminalExitStatus, TerminalLocationOptions, - TerminalLocation + TerminalLocation, + TerminalBuffer } from './base/terminal-widget'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { TerminalPreferences } from './terminal-preferences'; @@ -60,6 +61,23 @@ export interface TerminalContribution { onCreate(term: TerminalWidgetImpl): void; } +class TerminalBufferImpl implements TerminalBuffer { + constructor(private readonly term: Terminal) { + } + + get length(): number { + return this.term.buffer.active.length; + }; + getLines(start: number, length: number): string[] { + const result: string[] = []; + for (let i = 0; i < length && this.length - 1 - i >= 0; i++) { + result.push(this.term.buffer.active.getLine(this.length - 1 - i)!.translateToString()); + } + return result; + } + +} + @injectable() export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget, ExtractableWidget, EnhancedPreviewWidget { readonly isExtractable: boolean = true; @@ -123,6 +141,9 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget protected readonly onDataEmitter = new Emitter(); readonly onData: Event = this.onDataEmitter.event; + protected readonly onOutputEmitter = new Emitter(); + readonly onOutput: Event = this.onOutputEmitter.event; + protected readonly onKeyEmitter = new Emitter<{ key: string, domEvent: KeyboardEvent }>(); readonly onKey: Event<{ key: string, domEvent: KeyboardEvent }> = this.onKeyEmitter.event; @@ -134,6 +155,11 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget protected readonly toDisposeOnConnect = new DisposableCollection(); + private _buffer: TerminalBuffer; + override get buffer(): TerminalBuffer { + return this._buffer; + } + @postConstruct() protected init(): void { this.setTitle(this.options.title || TerminalWidgetImpl.LABEL); @@ -174,6 +200,7 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget fastScrollSensitivity: this.preferences['terminal.integrated.fastScrollSensitivity'], theme: this.themeService.theme }); + this._buffer = new TerminalBufferImpl(this.term); this.fitAddon = new FitAddon(); this.term.loadAddon(this.fitAddon); @@ -711,6 +738,7 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget write(data: string): void { if (this.termOpened) { this.term.write(data); + this.onOutputEmitter.fire(data); } else { this.initialData += data; } @@ -762,6 +790,7 @@ export class TerminalWidgetImpl extends TerminalWidget implements StatefulWidget writeLine(text: string): void { this.term.writeln(text); + this.onOutputEmitter.fire(text + '\n'); } get onTerminalDidClose(): Event {