From 290394b401c53e096f4659daea4eb1c3417e9e9d Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Thu, 3 Jan 2019 20:22:53 -0800 Subject: [PATCH] New addon prototype Part of #1128 --- demo/client.ts | 5 +- src/Terminal.test.ts | 2 +- src/Terminal.ts | 17 ++++- src/addons/webLinks/webLinks.ts | 20 +++++- src/public/Terminal.ts | 11 +++- src/ui/AddonManager.test.ts | 111 ++++++++++++++++++++++++++++++++ src/ui/AddonManager.ts | 68 +++++++++++++++++++ src/ui/TestUtils.test.ts | 11 +++- typings/xterm.d.ts | 23 +++++++ 9 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 src/ui/AddonManager.test.ts create mode 100644 src/ui/AddonManager.ts diff --git a/demo/client.ts b/demo/client.ts index a3a912f63..ae628baa1 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -29,7 +29,7 @@ Terminal.applyAddon(attach); Terminal.applyAddon(fit); Terminal.applyAddon(fullscreen); Terminal.applyAddon(search); -Terminal.applyAddon(webLinks); +// Terminal.applyAddon(webLinks); Terminal.applyAddon(winptyCompat); @@ -84,6 +84,7 @@ function createTerminal(): void { terminalContainer.removeChild(terminalContainer.children[0]); } term = new Terminal({}); + (term as TerminalType).loadAddon(webLinks.WebLinksAddon).init(); window.term = term; // Expose `term` to window for debugging purposes term.on('resize', (size: { cols: number, rows: number }) => { if (!pid) { @@ -100,7 +101,7 @@ function createTerminal(): void { term.open(terminalContainer); term.winptyCompatInit(); - term.webLinksInit(); + // term.webLinksInit(); term.fit(); term.focus(); diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts index fdc9678be..4807ddde6 100644 --- a/src/Terminal.test.ts +++ b/src/Terminal.test.ts @@ -16,7 +16,7 @@ class TestTerminal extends Terminal { public keyPress(ev: any): boolean { return this._keyPress(ev); } } -describe('term.js addons', () => { +describe('Terminal', () => { let term: TestTerminal; const termOptions = { cols: INIT_COLS, diff --git a/src/Terminal.ts b/src/Terminal.ts index bc97de29f..9e0ef6b9e 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -45,13 +45,14 @@ import { DEFAULT_ANSI_COLORS } from './renderer/ColorManager'; import { MouseZoneManager } from './ui/MouseZoneManager'; import { AccessibilityManager } from './AccessibilityManager'; import { ScreenDprMonitor } from './ui/ScreenDprMonitor'; -import { ITheme, IMarker, IDisposable } from 'xterm'; +import { ITheme, IMarker, IDisposable, ITerminalAddon, ITerminalAddonConstructor } from 'xterm'; import { removeTerminalFromCache } from './renderer/atlas/CharAtlasCache'; import { DomRenderer } from './renderer/dom/DomRenderer'; import { IKeyboardEvent } from './common/Types'; import { evaluateKeyboardEvent } from './core/input/Keyboard'; import { KeyboardResultType, ICharset } from './core/Types'; import { clone } from './common/Clone'; +import { AddonManager } from './ui/AddonManager'; // Let it work inside Node.js for automated testing purposes. const document = (typeof window !== 'undefined') ? window.document : null; @@ -203,6 +204,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II private _mouseZoneManager: IMouseZoneManager; public mouseHelper: MouseHelper; private _accessibilityManager: AccessibilityManager; + private _addonManager: AddonManager; private _screenDprMonitor: ScreenDprMonitor; private _theme: ITheme; @@ -309,6 +311,7 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II this.linkifier = this.linkifier || new Linkifier(this); this._mouseZoneManager = this._mouseZoneManager || null; this.soundManager = this.soundManager || new SoundManager(this); + this._addonManager = this._addonManager || new AddonManager(); // Create the terminal's buffers and set the current buffer this.buffers = new BufferSet(this); @@ -1933,6 +1936,18 @@ export class Terminal extends EventEmitter implements ITerminal, IDisposable, II // return this.options.bellStyle === 'sound' || // this.options.bellStyle === 'both'; } + + public loadAddon(addonConstructor: ITerminalAddonConstructor): T { + return this._addonManager.loadAddon(this, addonConstructor); + } + + public disposeAddon(addonConstructor: ITerminalAddonConstructor): void { + this._addonManager.disposeAddon(addonConstructor); + } + + public getAddon(addonConstructor: ITerminalAddonConstructor): T { + return this._addonManager.getAddon(addonConstructor); + } } /** diff --git a/src/addons/webLinks/webLinks.ts b/src/addons/webLinks/webLinks.ts index f0d69cc58..6fc6b25d4 100644 --- a/src/addons/webLinks/webLinks.ts +++ b/src/addons/webLinks/webLinks.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { Terminal, ILinkMatcherOptions } from 'xterm'; +import { Terminal, ILinkMatcherOptions, ITerminalAddon } from 'xterm'; const protocolClause = '(https?:\\/\\/)'; const domainCharacterSet = '[\\da-z\\.-]+'; @@ -35,12 +35,30 @@ function handleLink(event: MouseEvent, uri: string): void { * @param options Custom options to use, matchIndex will always be ignored. */ export function webLinksInit(term: Terminal, handler: (event: MouseEvent, uri: string) => void = handleLink, options: ILinkMatcherOptions = {}): void { + // TODO: Remove this options.matchIndex = 1; term.registerLinkMatcher(strictUrlRegex, handler, options); } export function apply(terminalConstructor: typeof Terminal): void { + // TODO: Remove this (terminalConstructor.prototype).webLinksInit = function (handler?: (event: MouseEvent, uri: string) => void, options?: ILinkMatcherOptions): void { webLinksInit(this, handler, options); }; } + +export class WebLinksAddon implements ITerminalAddon { + private _linkMatcherId: number; + + constructor(private _terminal: Terminal) { + } + + public init(handler: (event: MouseEvent, uri: string) => void = handleLink, options: ILinkMatcherOptions = {}): void { + options.matchIndex = 1; + this._linkMatcherId = this._terminal.registerLinkMatcher(strictUrlRegex, handler, options); + } + + public dispose(): void { + this._terminal.deregisterLinkMatcher(this._linkMatcherId); + } +} diff --git a/src/public/Terminal.ts b/src/public/Terminal.ts index 87fcfaef9..6f2de10f5 100644 --- a/src/public/Terminal.ts +++ b/src/public/Terminal.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { Terminal as ITerminalApi, ITerminalOptions, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings } from 'xterm'; +import { Terminal as ITerminalApi, ITerminalOptions, IMarker, IDisposable, ILinkMatcherOptions, ITheme, ILocalizableStrings, ITerminalAddon, ITerminalAddonConstructor } from 'xterm'; import { ITerminal } from '../Types'; import { Terminal as TerminalCore } from '../Terminal'; import * as Strings from '../Strings'; @@ -154,6 +154,15 @@ export class Terminal implements ITerminalApi { public static applyAddon(addon: any): void { addon.apply(Terminal); } + public loadAddon(addonConstructor: ITerminalAddonConstructor): T { + return this._core.loadAddon(addonConstructor); + } + public getAddon(addonConstructor: ITerminalAddonConstructor): T { + return this._core.getAddon(addonConstructor); + } + public disposeAddon(addonConstructor: ITerminalAddonConstructor): void { + this._core.disposeAddon(addonConstructor); + } public static get strings(): ILocalizableStrings { return Strings; } diff --git a/src/ui/AddonManager.test.ts b/src/ui/AddonManager.test.ts new file mode 100644 index 000000000..ac83917c2 --- /dev/null +++ b/src/ui/AddonManager.test.ts @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { AddonManager, ILoadedAddon } from './AddonManager'; +import { ITerminalAddon } from 'xterm'; + +class TestAddonManager extends AddonManager { + public get addons(): ILoadedAddon[] { + return this._addons; + } +} + +describe('AddonManager', () => { + let manager: TestAddonManager; + + beforeEach(() => { + manager = new TestAddonManager(); + }); + + describe('loadAddon', () => { + it('should call addon constructor', () => { + let called = false; + class Addon implements ITerminalAddon { + constructor(terminal: any) { + assert.equal(terminal, 'foo', 'The first constructor arg should be Terminal'); + called = true; + } + dispose(): void { } + } + manager.loadAddon('foo' as any, Addon); + assert.equal(called, true); + }); + }); + + describe('getAddon', () => { + it('should fetch registered addons', () => { + class BaseAddon implements ITerminalAddon { + constructor() { } + dispose(): void { } + } + class Addon1 extends BaseAddon { } + class Addon2 extends BaseAddon { } + class Addon3 extends BaseAddon { } + const addon1 = manager.loadAddon(null, Addon1); + assert.equal(manager.getAddon(Addon1), addon1); + assert.equal(manager.addons.length, 1); + const addon2 = manager.loadAddon(null, Addon2); + assert.equal(manager.getAddon(Addon1), addon1); + assert.equal(manager.getAddon(Addon2), addon2); + assert.equal(manager.addons.length, 2); + const addon3 = manager.loadAddon(null, Addon3); + assert.equal(manager.getAddon(Addon1), addon1); + assert.equal(manager.getAddon(Addon2), addon2); + assert.equal(manager.getAddon(Addon3), addon3); + assert.equal(manager.addons.length, 3); + }); + }); + + describe('disposeAddon', () => { + it('should dispose the loaded addon and remove it from the loaded list', () => { + let called = 0; + class BaseAddon implements ITerminalAddon { + constructor() { } + dispose(): void { + called++; + } + } + class Addon1 extends BaseAddon { } + class Addon2 extends BaseAddon { } + class Addon3 extends BaseAddon { } + manager.loadAddon(null, Addon1); + manager.loadAddon(null, Addon2); + manager.loadAddon(null, Addon3); + assert.equal(manager.addons.length, 3); + manager.disposeAddon(Addon1); + assert.equal(called, 1); + assert.equal(manager.addons.length, 2); + manager.disposeAddon(Addon2); + assert.equal(called, 2); + assert.equal(manager.addons.length, 1); + manager.disposeAddon(Addon3); + assert.equal(called, 3); + assert.equal(manager.addons.length, 0); + }); + }); + + describe('dispose', () => { + it('should dispose all loaded addons', () => { + let called = 0; + class BaseAddon implements ITerminalAddon { + constructor() { } + dispose(): void { + called++; + } + } + class Addon1 extends BaseAddon { } + class Addon2 extends BaseAddon { } + class Addon3 extends BaseAddon { } + manager.loadAddon(null, Addon1); + manager.loadAddon(null, Addon2); + manager.loadAddon(null, Addon3); + assert.equal(manager.addons.length, 3); + manager.dispose(); + assert.equal(called, 3); + assert.equal(manager.addons.length, 0); + }); + }); +}); diff --git a/src/ui/AddonManager.ts b/src/ui/AddonManager.ts new file mode 100644 index 000000000..b55bd6a89 --- /dev/null +++ b/src/ui/AddonManager.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ITerminalAddon, ITerminalAddonConstructor, IDisposable, Terminal } from 'xterm'; + +export interface ILoadedAddon { + ctor: ITerminalAddonConstructor; + instance: ITerminalAddon; + dispose: () => void; +} + +export class AddonManager implements IDisposable { + protected _addons: ILoadedAddon[] = []; + + constructor() { + } + + public dispose(): void { + for (let i = this._addons.length - 1; i >= 0; i--) { + this._addons[i].instance.dispose(); + } + } + + public loadAddon(terminal: Terminal, addonConstructor: ITerminalAddonConstructor): T { + const instance = new addonConstructor(terminal); + const loadedAddon: ILoadedAddon = { + ctor: addonConstructor, + instance, + dispose: instance.dispose + }; + this._addons.push(loadedAddon); + instance.dispose = () => this._wrappedAddonDispose(loadedAddon); + return instance; + } + + public disposeAddon(addonConstructor: ITerminalAddonConstructor): void { + const match = this._addons.find(value => value.ctor === addonConstructor); + if (!match) { + throw new Error('Could not dispose an addon that has not been loaded'); + } + match.instance.dispose(); + } + + public getAddon(addonConstructor: ITerminalAddonConstructor): T { + const match = this._addons.find(value => value.ctor === addonConstructor); + if (!match) { + return undefined; + } + return match.instance as T; + } + + private _wrappedAddonDispose(loadedAddon: ILoadedAddon): void { + let index = -1; + for (let i = 0; i < this._addons.length; i++) { + if (this._addons[i].ctor === loadedAddon.ctor) { + index = i; + break; + } + } + if (index === -1) { + throw new Error('Could not dispose an addon that has not been loaded'); + } + loadedAddon.dispose(); + this._addons.splice(index, 1); + } +} diff --git a/src/ui/TestUtils.test.ts b/src/ui/TestUtils.test.ts index e6e4aaa32..fdcdd4e58 100644 --- a/src/ui/TestUtils.test.ts +++ b/src/ui/TestUtils.test.ts @@ -8,7 +8,7 @@ import { IInputHandlingTerminal, IViewport, ICompositionHelper, ITerminal, IBuff import { ICircularList, XtermListener } from '../common/Types'; import { Buffer } from '../Buffer'; import * as Browser from '../core/Platform'; -import { ITheme, IDisposable, IMarker } from 'xterm'; +import { ITheme, IDisposable, IMarker, ITerminalAddon, ITerminalAddonConstructor } from 'xterm'; import { Terminal } from '../Terminal'; export class TestTerminal extends Terminal { @@ -19,6 +19,15 @@ export class TestTerminal extends Terminal { } export class MockTerminal implements ITerminal { + loadAddon(addonConstructor: ITerminalAddonConstructor): T { + throw new Error('Method not implemented.'); + } + disposeAddon(addonConstructor: ITerminalAddonConstructor): void { + throw new Error('Method not implemented.'); + } + getAddon(addonConstructor: ITerminalAddonConstructor): T { + throw new Error('Method not implemented.'); + } markers: IMarker[]; addMarker(cursorYOffset: number): IMarker { throw new Error('Method not implemented.'); diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index c5d2b0204..d73a9156d 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -753,5 +753,28 @@ declare module 'xterm' { * @param addon The addon to apply. */ static applyAddon(addon: any): void; + + loadAddon(addonConstructor: ITerminalAddonConstructor): T; + disposeAddon(addonConstructor: ITerminalAddonConstructor): void; + getAddon(addonConstructor: ITerminalAddonConstructor): T; + } + + export interface ITerminalAddonConstructor { + new(terminal: Terminal): T; + } + + export interface ITerminalAddon { + /** + * This property declares all addon dependencies that must be intialized + * before this addon can be constructed. For addons with no dependencies + * just don't include this property. + */ + // readonly DEPENDENCIES?: ITerminalAddonConstructor[]; + + /** + * This function includes anything that needs to happen to clean up when + * the addon is being disposed. + */ + dispose(): void; } }