From ed9d9d32dd64bfec8f420991a11130964c3592bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Mon, 4 May 2020 17:24:42 +0200 Subject: [PATCH 1/4] first working prototype --- addons/xterm-addon-hyperlinks/.gitignore | 2 + addons/xterm-addon-hyperlinks/.npmignore | 5 + addons/xterm-addon-hyperlinks/LICENSE | 19 ++ addons/xterm-addon-hyperlinks/README.md | 18 ++ addons/xterm-addon-hyperlinks/package.json | 21 ++ .../src/HyperlinksAddon.api.ts | 49 +++++ .../src/HyperlinksAddon.ts | 191 ++++++++++++++++++ .../xterm-addon-hyperlinks/src/tsconfig.json | 34 ++++ .../typings/xterm-addon-hyperlinks.d.ts | 14 ++ .../xterm-addon-hyperlinks/webpack.config.js | 38 ++++ .../src/WebLinkProvider.ts | 2 +- demo/client.ts | 11 +- src/browser/Linkifier2.test.ts | 20 +- src/browser/Linkifier2.ts | 48 +++-- src/browser/Types.d.ts | 2 +- src/common/Types.d.ts | 1 + src/common/buffer/AttributeData.ts | 8 +- tsconfig.all.json | 1 + typings/xterm.d.ts | 2 +- 19 files changed, 450 insertions(+), 36 deletions(-) create mode 100644 addons/xterm-addon-hyperlinks/.gitignore create mode 100644 addons/xterm-addon-hyperlinks/.npmignore create mode 100644 addons/xterm-addon-hyperlinks/LICENSE create mode 100644 addons/xterm-addon-hyperlinks/README.md create mode 100644 addons/xterm-addon-hyperlinks/package.json create mode 100644 addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts create mode 100644 addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts create mode 100644 addons/xterm-addon-hyperlinks/src/tsconfig.json create mode 100644 addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts create mode 100644 addons/xterm-addon-hyperlinks/webpack.config.js diff --git a/addons/xterm-addon-hyperlinks/.gitignore b/addons/xterm-addon-hyperlinks/.gitignore new file mode 100644 index 0000000000..3063f07d55 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/.gitignore @@ -0,0 +1,2 @@ +lib +node_modules diff --git a/addons/xterm-addon-hyperlinks/.npmignore b/addons/xterm-addon-hyperlinks/.npmignore new file mode 100644 index 0000000000..1c79444565 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/.npmignore @@ -0,0 +1,5 @@ +**/*.api.js +**/*.api.ts +tsconfig.json +.yarnrc +webpack.config.js diff --git a/addons/xterm-addon-hyperlinks/LICENSE b/addons/xterm-addon-hyperlinks/LICENSE new file mode 100644 index 0000000000..c1735c41a7 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/addons/xterm-addon-hyperlinks/README.md b/addons/xterm-addon-hyperlinks/README.md new file mode 100644 index 0000000000..a6db2910e8 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/README.md @@ -0,0 +1,18 @@ +## xterm-addon-hyperlinks + +An addon providing support for the hyperlinks terminal sequence with `OSC 8`. + +### Install + +```bash +npm install --save xterm-addon-hyperlinks +``` + +### Usage + +```ts +import { Terminal } from 'xterm'; +import { HyperlinksAddon } from 'xterm-addon-hyperlinks'; + +// TODO... +``` diff --git a/addons/xterm-addon-hyperlinks/package.json b/addons/xterm-addon-hyperlinks/package.json new file mode 100644 index 0000000000..2caec78e99 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/package.json @@ -0,0 +1,21 @@ +{ + "name": "xterm-addon-hyperlinks", + "version": "0.1.0", + "author": { + "name": "The xterm.js authors", + "url": "https://xtermjs.org/" + }, + "main": "lib/xterm-addon-hyperlinks.js", + "types": "typings/xterm-addon-hyperlinks.d.ts", + "repository": "https://github.com/xtermjs/xterm.js", + "license": "MIT", + "scripts": { + "build": "../../node_modules/.bin/tsc -p src", + "prepackage": "npm run build", + "package": "../../node_modules/.bin/webpack", + "prepublishOnly": "npm run package" + }, + "peerDependencies": { + "xterm": "^4.0.0" + } +} diff --git a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts new file mode 100644 index 0000000000..69f1affe24 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { openTerminal, getBrowserType } from '../../../out-test/api/TestUtils'; +import { Browser, Page } from 'playwright-core'; + +const APP = 'http://127.0.0.1:3000/test'; + +let browser: Browser; +let page: Page; +const width = 800; +const height = 600; + +describe('HyperlinksAddon', () => { + before(async function(): Promise { + const browserType = getBrowserType(); + browser = await browserType.launch({ dumpio: true, + headless: process.argv.indexOf('--headless') !== -1 + }); + page = await (await browser.newContext()).newPage(); + await page.setViewportSize({ width, height }); + }); + + after(async () => { + await browser.close(); + }); + + beforeEach(async function(): Promise { + await page.goto(APP); + await openTerminal(page); + }); + + //it('wcwidth V11 emoji test', async () => { + // await page.evaluate(` + // window.unicode11 = new Unicode11Addon(); + // window.term.loadAddon(window.unicode11); + // `); + // // should have loaded '11' + // assert.deepEqual(await page.evaluate(`window.term.unicode.versions`), ['6', '11']); + // // switch should not throw + // await page.evaluate(`window.term.unicode.activeVersion = '11';`); + // assert.deepEqual(await page.evaluate(`window.term.unicode.activeVersion`), '11'); + // // v6: 10, V11: 20 + // assert.deepEqual(await page.evaluate(`window.term._core.unicodeService.getStringCellWidth('🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣')`), 20); + //}); +}); diff --git a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts new file mode 100644 index 0000000000..c764c75bbb --- /dev/null +++ b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts @@ -0,0 +1,191 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + * + * UnicodeVersionProvider for V11. + */ + +import { Terminal, ITerminalAddon, IDisposable, IBufferRange } from 'xterm'; +import { ILinkProvider, IBufferCellPosition, ILink } from 'xterm'; + +/** + * TODO: + * Need the following changes in xterm.js: + * - allow LinkProvider callback to contain multiple ranges (currently hacked) + * - make extended attributes contributable by outer code + * - extend public API by extended attributes + */ + +class HyperlinkProvider implements ILinkProvider { + + constructor( + private readonly _terminal: Terminal, + private readonly _urlMap: {[key: number]: string} + ) {} + + public provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void { + const pos = {x: position.x - 1, y: position.y - 1}; + + // test whether we are above a cell with an urlId + // TODO: need API extension + const urlId = (this._terminal.buffer.active.getLine(pos.y)?.getCell(pos.x) as any).extended.urlId; + if (!urlId) { + callback(undefined); + return; + } + + // we got an url cell, thus we need to fetch whole viewport and + // mark all cells with that particular urlId + const yOffset = this._terminal.buffer.active.viewportY; + const ranges: IBufferRange[] = []; + let r: IBufferRange | null = null; + for (let y = yOffset; y < this._terminal.rows + yOffset; ++y) { + const line = this._terminal.buffer.active.getLine(y); + if (!line) { + break; + } + for (let x = 0; x < this._terminal.rows; ++x) { + const cell = line.getCell(x); + if (!cell) { + break; + } + if ((cell as any).extended.urlId === urlId) { + if (!r) { + r = {start: {x, y}, end: {x: x + 1, y}}; + } else { + r.end.x = x + 1; + r.end.y = y; + } + } else { + if (r) { + r.end.x = x; + r.end.y = y; + ranges.push(r); + r = null; + } + } + } + } + if (r) { + ranges.push(r); + } + + // fix ranges to 1-based, right inclusive + for (let r of ranges) { + r.start.x++; + r.start.y++; + r.end.y++; + } + + // TODO: make this customizable from outside + callback({ + ranges, + text: this._urlMap[urlId], + decorations: {pointerCursor: true, underline: true}, + activate: (event: MouseEvent, text: string) => { + console.log('would open:', text); + }, + hover: (event: MouseEvent, text: string) => { + console.log('tooltip to show:', text); + } + }); + } +} + +export class HyperlinksAddon implements ITerminalAddon { + private _oscHandler: IDisposable | undefined; + private _linkProvider: IDisposable | undefined; + private _internalId = 1; + private _idMap: {[key: string]: number} = {}; + private _urlMap: {[key: number]: string} = {}; + + /** + * Parse params part of the data. + * Converts into a key-value mapping object if: + * - fully empty (returns empty object) + * - all keys and values are set + * - key-value pairs are properly separated by ':' + * - keys and values are separated by '=' + * - a key-value pair can be empty + * Any other case will drop to `false` as return value. + */ + private _parseParams(paramString: string): {[key: string]: string} | void { + const result: {[key: string]: string} = {}; + const params = paramString.split(':').filter(Boolean).map(el => el.split('=')); + for (const p of params) { + if (p.length !== 2) { + return; + } + const [key, value] = p; + if (!key || !value) { + return; + } + result[key] = value; + } + return result; + } + + /** + * Method to filter for allowed URL schemes. + * Returns empty string if the given url does not pass the test. + */ + private _filterUrl(urlString: string): string { + // TODO: implement filter rules + return urlString; + } + + private _updateAttrs(terminal: Terminal, hoverId: number = 0): void { + // hack: remove url notion from extended attrs by private access + // TODO: API to contribute to extended attributes + const attr = (terminal as any)._core._inputHandler._curAttrData; + attr.extended = attr.extended.clone(); + attr.extended.urlId = hoverId; + attr.updateExtended(); + } + + public activate(terminal: Terminal): void { + this._oscHandler = terminal.parser.registerOscHandler(8, data => { + + this._updateAttrs(terminal); + + if (data.indexOf(';') === -1) { + return true; + } + const params = this._parseParams(data.slice(0, data.indexOf(';'))); + const url = this._filterUrl(data.slice(data.indexOf(';') + 1)); + console.log(params, url); + + if (!url) { + return true; + } + + let hoverId; + if (params && params.id) { + // check whether we already know that id and url + const oldInternal = this._idMap[params.id]; + if (oldInternal && this._urlMap[oldInternal] === url) { + hoverId = oldInternal; + } else { + hoverId = this._internalId++; + this._idMap[params.id] = hoverId; + this._urlMap[hoverId] = url; + } + } else { + hoverId = this._internalId++; + this._urlMap[hoverId] = url; + } + + this._updateAttrs(terminal, hoverId); + + return true; + }); + this._linkProvider = terminal.registerLinkProvider(new HyperlinkProvider(terminal, this._urlMap)); + } + + public dispose(): void { + this._oscHandler?.dispose(); + this._linkProvider?.dispose(); + this._idMap = {}; + this._urlMap = {}; + } +} diff --git a/addons/xterm-addon-hyperlinks/src/tsconfig.json b/addons/xterm-addon-hyperlinks/src/tsconfig.json new file mode 100644 index 0000000000..f9d0ab5fab --- /dev/null +++ b/addons/xterm-addon-hyperlinks/src/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2015", + "lib": [ + "dom", + "es2015" + ], + "rootDir": ".", + "outDir": "../out", + "sourceMap": true, + "removeComments": true, + "strict": true, + "baseUrl": ".", + "paths": { + "common/*": [ + "../../../src/common/*" + ] + }, + "types": [ + "../../../node_modules/@types/mocha", + "../../../out-test/api/TestUtils" + ] + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ], + "references": [ + { + "path": "../../../src/common" + } + ] +} diff --git a/addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts b/addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts new file mode 100644 index 0000000000..18d6fbfb4f --- /dev/null +++ b/addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, ITerminalAddon } from 'xterm'; + +declare module 'xterm-addon-hyperlinks' { + export class HyperlinksAddon implements ITerminalAddon { + constructor(); + public activate(terminal: Terminal): void; + public dispose(): void; + } +} diff --git a/addons/xterm-addon-hyperlinks/webpack.config.js b/addons/xterm-addon-hyperlinks/webpack.config.js new file mode 100644 index 0000000000..47901f6a67 --- /dev/null +++ b/addons/xterm-addon-hyperlinks/webpack.config.js @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2019 The xterm.js authors. All rights reserved. + * @license MIT + */ + +const path = require('path'); + +const addonName = 'HyperlinksAddon'; +const mainFile = 'xterm-addon-hyperlinks.js'; + +module.exports = { + entry: `./out/${addonName}.js`, + devtool: 'source-map', + module: { + rules: [ + { + test: /\.js$/, + use: ["source-map-loader"], + enforce: "pre", + exclude: /node_modules/ + } + ] + }, + resolve: { + modules: ['./node_modules'], + extensions: [ '.js' ], + alias: { + common: path.resolve('../../out/common') + } + }, + output: { + filename: mainFile, + path: path.resolve('./lib'), + library: addonName, + libraryTarget: 'umd' + }, + mode: 'production' +}; diff --git a/addons/xterm-addon-web-links/src/WebLinkProvider.ts b/addons/xterm-addon-web-links/src/WebLinkProvider.ts index 563108d29e..1d1fa621b0 100644 --- a/addons/xterm-addon-web-links/src/WebLinkProvider.ts +++ b/addons/xterm-addon-web-links/src/WebLinkProvider.ts @@ -68,7 +68,7 @@ export class LinkComputer { } }; - return { range, text, activate: handler }; + return { ranges: [range], text, activate: handler }; } } diff --git a/demo/client.ts b/demo/client.ts index 3cb1b3ec73..d7475531a7 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -11,6 +11,7 @@ import { Terminal } from '../out/browser/public/Terminal'; import { AttachAddon } from '../addons/xterm-addon-attach/out/AttachAddon'; import { FitAddon } from '../addons/xterm-addon-fit/out/FitAddon'; +import { HyperlinksAddon } from '../addons/xterm-addon-hyperlinks/out/HyperlinksAddon'; import { SearchAddon, ISearchOptions } from '../addons/xterm-addon-search/out/SearchAddon'; import { SerializeAddon } from '../addons/xterm-addon-serialize/out/SerializeAddon'; import { WebLinksAddon } from '../addons/xterm-addon-web-links/out/WebLinksAddon'; @@ -36,6 +37,7 @@ export interface IWindowWithTerminal extends Window { Terminal?: typeof TerminalType; AttachAddon?: typeof AttachAddon; FitAddon?: typeof FitAddon; + HyperlinksAddon?: typeof HyperlinksAddon; SearchAddon?: typeof SearchAddon; SerializeAddon?: typeof SerializeAddon; WebLinksAddon?: typeof WebLinksAddon; @@ -50,7 +52,7 @@ let socketURL; let socket; let pid; -type AddonType = 'attach' | 'fit' | 'search' | 'serialize' | 'unicode11' | 'web-links' | 'webgl'; +type AddonType = 'attach' | 'fit' | 'hyperlinks' | 'search' | 'serialize' | 'unicode11' | 'web-links' | 'webgl'; interface IDemoAddon { name: T; @@ -58,6 +60,7 @@ interface IDemoAddon { ctor: T extends 'attach' ? typeof AttachAddon : T extends 'fit' ? typeof FitAddon : + T extends 'hyperlinks' ? typeof HyperlinksAddon: T extends 'search' ? typeof SearchAddon : T extends 'serialize' ? typeof SerializeAddon : T extends 'web-links' ? typeof WebLinksAddon : @@ -66,6 +69,7 @@ interface IDemoAddon { instance?: T extends 'attach' ? AttachAddon : T extends 'fit' ? FitAddon : + T extends 'hyperlinks' ? typeof HyperlinksAddon: T extends 'search' ? SearchAddon : T extends 'serialize' ? SerializeAddon : T extends 'web-links' ? WebLinksAddon : @@ -77,6 +81,7 @@ interface IDemoAddon { const addons: { [T in AddonType]: IDemoAddon} = { attach: { name: 'attach', ctor: AttachAddon, canChange: false }, fit: { name: 'fit', ctor: FitAddon, canChange: false }, + hyperlinks: { name: 'hyperlinks', ctor: HyperlinksAddon, canChange: true }, search: { name: 'search', ctor: SearchAddon, canChange: true }, serialize: { name: 'serialize', ctor: SerializeAddon, canChange: true }, 'web-links': { name: 'web-links', ctor: WebLinksAddon, canChange: true }, @@ -114,6 +119,7 @@ const disposeRecreateButtonHandler = () => { socket = null; addons.attach.instance = undefined; addons.fit.instance = undefined; + addons.hyperlinks.instance = undefined; addons.search.instance = undefined; addons.serialize.instance = undefined; addons.unicode11.instance = undefined; @@ -130,6 +136,7 @@ if (document.location.pathname === '/test') { window.Terminal = Terminal; window.AttachAddon = AttachAddon; window.FitAddon = FitAddon; + window.HyperlinksAddon = HyperlinksAddon; window.SearchAddon = SearchAddon; window.SerializeAddon = SerializeAddon; window.Unicode11Addon = Unicode11Addon; @@ -157,10 +164,12 @@ function createTerminal(): void { addons.search.instance = new SearchAddon(); addons.serialize.instance = new SerializeAddon(); addons.fit.instance = new FitAddon(); + addons.hyperlinks.instance = new HyperlinksAddon(); addons.unicode11.instance = new Unicode11Addon(); // TODO: Remove arguments when link provider API is the default addons['web-links'].instance = new WebLinksAddon(undefined, undefined, true); typedTerm.loadAddon(addons.fit.instance); + typedTerm.loadAddon(addons.hyperlinks.instance); typedTerm.loadAddon(addons.search.instance); typedTerm.loadAddon(addons.serialize.instance); typedTerm.loadAddon(addons.unicode11.instance); diff --git a/src/browser/Linkifier2.test.ts b/src/browser/Linkifier2.test.ts index fb71b6aaa4..f309a3b301 100644 --- a/src/browser/Linkifier2.test.ts +++ b/src/browser/Linkifier2.test.ts @@ -42,7 +42,7 @@ describe('Linkifier2', () => { const link: ILink = { text: 'foo', - range: { + ranges: [{ start: { x: 5, y: 1 @@ -51,16 +51,16 @@ describe('Linkifier2', () => { x: 7, y: 1 } - }, + }], activate: () => { } }; it('onShowLinkUnderline event range is correct', done => { linkifier.onShowLinkUnderline(e => { - assert.equal(link.range.start.x - 1, e.x1); - assert.equal(link.range.start.y - 1, e.y1); - assert.equal(link.range.end.x, e.x2); - assert.equal(link.range.end.y - 1, e.y2); + assert.equal(link.ranges[0].start.x - 1, e.x1); + assert.equal(link.ranges[0].start.y - 1, e.y1); + assert.equal(link.ranges[0].end.x, e.x2); + assert.equal(link.ranges[0].end.y - 1, e.y2); done(); }); @@ -70,10 +70,10 @@ describe('Linkifier2', () => { it('onHideLinkUnderline event range is correct', done => { linkifier.onHideLinkUnderline(e => { - assert.equal(link.range.start.x - 1, e.x1); - assert.equal(link.range.start.y - 1, e.y1); - assert.equal(link.range.end.x, e.x2); - assert.equal(link.range.end.y - 1, e.y2); + assert.equal(link.ranges[0].start.x - 1, e.x1); + assert.equal(link.ranges[0].start.y - 1, e.y1); + assert.equal(link.ranges[0].end.x, e.x2); + assert.equal(link.ranges[0].end.y - 1, e.y2); done(); }); diff --git a/src/browser/Linkifier2.ts b/src/browser/Linkifier2.ts index 39f750cc02..2e62743f25 100644 --- a/src/browser/Linkifier2.ts +++ b/src/browser/Linkifier2.ts @@ -178,7 +178,7 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { } // If we have a start and end row, check that the link is within it - if (!startRow || !endRow || (this._currentLink.range.start.y >= startRow && this._currentLink.range.end.y <= endRow)) { + if (!startRow || !endRow || (this._currentLink.ranges[0].start.y >= startRow && this._currentLink.ranges[0].end.y <= endRow)) { this._linkLeave(this._element, this._currentLink, this._lastMouseEvent); this._currentLink = undefined; this._currentLinkState = undefined; @@ -266,11 +266,13 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { } private _fireUnderlineEvent(link: ILink, showEvent: boolean): void { - const range = link.range; - const scrollOffset = this._bufferService.buffer.ydisp; - const event = this._createLinkUnderlineEvent(range.start.x - 1, range.start.y - scrollOffset - 1, range.end.x, range.end.y - scrollOffset - 1, undefined); - const emitter = showEvent ? this._onShowLinkUnderline : this._onHideLinkUnderline; - emitter.fire(event); + // FIXME: dirty hack to get everything underlined, needs proper handling + for (const range of link.ranges) { + const scrollOffset = this._bufferService.buffer.ydisp; + const event = this._createLinkUnderlineEvent(range.start.x - 1, range.start.y - scrollOffset - 1, range.end.x, range.end.y - scrollOffset - 1, undefined); + const emitter = showEvent ? this._onShowLinkUnderline : this._onHideLinkUnderline; + emitter.fire(event); + } } protected _linkLeave(element: HTMLElement, link: ILink, event: MouseEvent): void { @@ -294,19 +296,27 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { * @param link * @param position */ - private _linkAtPosition(link: ILink, position: IBufferCellPosition): boolean { - const sameLine = link.range.start.y === link.range.end.y; - const wrappedFromLeft = link.range.start.y < position.y; - const wrappedToRight = link.range.end.y > position.y; - - // If the start and end have the same y, then the position must be between start and end x - // If not, then handle each case seperately, depending on which way it wraps - return ((sameLine && link.range.start.x <= position.x && link.range.end.x >= position.x) || - (wrappedFromLeft && link.range.end.x >= position.x) || - (wrappedToRight && link.range.start.x <= position.x) || - (wrappedFromLeft && wrappedToRight)) && - link.range.start.y <= position.y && - link.range.end.y >= position.y; + //private _linkAtPosition(link: ILink, position: IBufferCellPosition): boolean { + // const sameLine = link.range.start.y === link.range.end.y; + // const wrappedFromLeft = link.range.start.y < position.y; + // const wrappedToRight = link.range.end.y > position.y; + // + // // If the start and end have the same y, then the position must be between start and end x + // // If not, then handle each case seperately, depending on which way it wraps + // return ((sameLine && link.range.start.x <= position.x && link.range.end.x >= position.x) || + // (wrappedFromLeft && link.range.end.x >= position.x) || + // (wrappedToRight && link.range.start.x <= position.x) || + // (wrappedFromLeft && wrappedToRight)) && + // link.range.start.y <= position.y && + // link.range.end.y >= position.y; + //} + private _linkAtPosition(link: ILink, pos: IBufferCellPosition): boolean { + for (let r of link.ranges) { + if (r.end.y >= pos.y && r.end.x >= pos.x && r.start.y <= pos.y && r.start.x <= pos.x) { + return true; + } + } + return false; } /** diff --git a/src/browser/Types.d.ts b/src/browser/Types.d.ts index 76aa0c8cfe..15d6c6da1b 100644 --- a/src/browser/Types.d.ts +++ b/src/browser/Types.d.ts @@ -268,7 +268,7 @@ interface ILinkProvider { } interface ILink { - range: IBufferRange; + ranges: IBufferRange[]; text: string; decorations?: ILinkDecorations; activate(event: MouseEvent, text: string): void; diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 6ce69483d9..b7809ec85f 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -87,6 +87,7 @@ export type IColorRGB = [number, number, number]; export interface IExtendedAttrs { underlineStyle: number; underlineColor: number; + urlId: number; clone(): IExtendedAttrs; isEmpty(): boolean; } diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index c7217a2a0c..8173ec9f90 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -130,11 +130,13 @@ export class ExtendedAttrs implements IExtendedAttrs { // underline style, NONE is empty public underlineStyle: UnderlineStyle = UnderlineStyle.NONE, // underline color, -1 is empty (same as FG) - public underlineColor: number = -1 + public underlineColor: number = -1, + // url hover id, 0 is empty + public urlId = 0 ) {} public clone(): IExtendedAttrs { - return new ExtendedAttrs(this.underlineStyle, this.underlineColor); + return new ExtendedAttrs(this.underlineStyle, this.underlineColor, this.urlId); } /** @@ -142,6 +144,6 @@ export class ExtendedAttrs implements IExtendedAttrs { * that needs to be persistant in the buffer. */ public isEmpty(): boolean { - return this.underlineStyle === UnderlineStyle.NONE; + return this.underlineStyle === UnderlineStyle.NONE && !this.urlId; } } diff --git a/tsconfig.all.json b/tsconfig.all.json index a4286deba8..a7bd9a91c2 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -7,6 +7,7 @@ { "path": "./test/benchmark" }, { "path": "./addons/xterm-addon-attach/src" }, { "path": "./addons/xterm-addon-fit/src" }, + { "path": "./addons/xterm-addon-hyperlinks/src" }, { "path": "./addons/xterm-addon-ligatures/src" }, { "path": "./addons/xterm-addon-search/src" }, { "path": "./addons/xterm-addon-serialize/src" }, diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index dfda85831b..da02e95d80 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -1114,7 +1114,7 @@ declare module 'xterm' { /** * The buffer range of the link. */ - range: IBufferRange; + ranges: IBufferRange[]; /** * The text of the link. From 62d3eff5ad079d70a91d9478180317660ea0668c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Mon, 4 May 2020 17:54:10 +0200 Subject: [PATCH 2/4] fix linter issues --- .eslintrc.json | 1 + .../src/HyperlinksAddon.api.ts | 14 +-------- .../src/HyperlinksAddon.ts | 5 ++-- src/browser/Linkifier2.ts | 30 ++++++++++--------- 4 files changed, 20 insertions(+), 30 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index f234e93cc5..20b111cbce 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -13,6 +13,7 @@ "test/benchmark/tsconfig.json", "addons/xterm-addon-attach/src/tsconfig.json", "addons/xterm-addon-fit/src/tsconfig.json", + "addons/xterm-addon-hyperlinks/src/tsconfig.json", "addons/xterm-addon-search/src/tsconfig.json", "addons/xterm-addon-unicode11/src/tsconfig.json", "addons/xterm-addon-web-links/src/tsconfig.json", diff --git a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts index 69f1affe24..7282546e7c 100644 --- a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts +++ b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.api.ts @@ -33,17 +33,5 @@ describe('HyperlinksAddon', () => { await openTerminal(page); }); - //it('wcwidth V11 emoji test', async () => { - // await page.evaluate(` - // window.unicode11 = new Unicode11Addon(); - // window.term.loadAddon(window.unicode11); - // `); - // // should have loaded '11' - // assert.deepEqual(await page.evaluate(`window.term.unicode.versions`), ['6', '11']); - // // switch should not throw - // await page.evaluate(`window.term.unicode.activeVersion = '11';`); - // assert.deepEqual(await page.evaluate(`window.term.unicode.activeVersion`), '11'); - // // v6: 10, V11: 20 - // assert.deepEqual(await page.evaluate(`window.term._core.unicodeService.getStringCellWidth('🤣🤣🤣🤣🤣🤣🤣🤣🤣🤣')`), 20); - //}); + // TODO: write some tests }); diff --git a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts index c764c75bbb..6350ef36cf 100644 --- a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts +++ b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts @@ -5,8 +5,7 @@ * UnicodeVersionProvider for V11. */ -import { Terminal, ITerminalAddon, IDisposable, IBufferRange } from 'xterm'; -import { ILinkProvider, IBufferCellPosition, ILink } from 'xterm'; +import { Terminal, ITerminalAddon, IDisposable, IBufferRange, ILinkProvider, IBufferCellPosition, ILink } from 'xterm'; /** * TODO: @@ -71,7 +70,7 @@ class HyperlinkProvider implements ILinkProvider { } // fix ranges to 1-based, right inclusive - for (let r of ranges) { + for (const r of ranges) { r.start.x++; r.start.y++; r.end.y++; diff --git a/src/browser/Linkifier2.ts b/src/browser/Linkifier2.ts index 2e62743f25..c317ebe22a 100644 --- a/src/browser/Linkifier2.ts +++ b/src/browser/Linkifier2.ts @@ -178,6 +178,7 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { } // If we have a start and end row, check that the link is within it + // FIXME: needs proper handling of multiple ranges if (!startRow || !endRow || (this._currentLink.ranges[0].start.y >= startRow && this._currentLink.ranges[0].end.y <= endRow)) { this._linkLeave(this._element, this._currentLink, this._lastMouseEvent); this._currentLink = undefined; @@ -296,22 +297,23 @@ export class Linkifier2 extends Disposable implements ILinkifier2 { * @param link * @param position */ - //private _linkAtPosition(link: ILink, position: IBufferCellPosition): boolean { - // const sameLine = link.range.start.y === link.range.end.y; - // const wrappedFromLeft = link.range.start.y < position.y; - // const wrappedToRight = link.range.end.y > position.y; + // private _linkAtPosition(link: ILink, position: IBufferCellPosition): boolean { + // const sameLine = link.range.start.y === link.range.end.y; + // const wrappedFromLeft = link.range.start.y < position.y; + // const wrappedToRight = link.range.end.y > position.y; // - // // If the start and end have the same y, then the position must be between start and end x - // // If not, then handle each case seperately, depending on which way it wraps - // return ((sameLine && link.range.start.x <= position.x && link.range.end.x >= position.x) || - // (wrappedFromLeft && link.range.end.x >= position.x) || - // (wrappedToRight && link.range.start.x <= position.x) || - // (wrappedFromLeft && wrappedToRight)) && - // link.range.start.y <= position.y && - // link.range.end.y >= position.y; - //} + // // If the start and end have the same y, then the position must be between start and end x + // // If not, then handle each case seperately, depending on which way it wraps + // return ((sameLine && link.range.start.x <= position.x && link.range.end.x >= position.x) || + // (wrappedFromLeft && link.range.end.x >= position.x) || + // (wrappedToRight && link.range.start.x <= position.x) || + // (wrappedFromLeft && wrappedToRight)) && + // link.range.start.y <= position.y && + // link.range.end.y >= position.y; + // } + // FIXME: proper test against multiple ranges private _linkAtPosition(link: ILink, pos: IBufferCellPosition): boolean { - for (let r of link.ranges) { + for (const r of link.ranges) { if (r.end.y >= pos.y && r.end.x >= pos.x && r.start.y <= pos.y && r.start.x <= pos.x) { return true; } From 393aaf9b282812a388431064323bb91ab4d3cd73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Mon, 4 May 2020 23:47:59 +0200 Subject: [PATCH 3/4] configureable schemes, some cleanup --- .../src/HyperlinksAddon.ts | 252 ++++++++++++------ .../typings/xterm-addon-hyperlinks.d.ts | 8 +- demo/client.ts | 10 +- 3 files changed, 183 insertions(+), 87 deletions(-) diff --git a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts index 6350ef36cf..58d6e51d60 100644 --- a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts +++ b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts @@ -10,93 +10,57 @@ import { Terminal, ITerminalAddon, IDisposable, IBufferRange, ILinkProvider, IBu /** * TODO: * Need the following changes in xterm.js: - * - allow LinkProvider callback to contain multiple ranges (currently hacked) + * - allow LinkProvider callback to contain multiple ranges (currently sloppy hacked into Linkifier2) * - make extended attributes contributable by outer code * - extend public API by extended attributes */ -class HyperlinkProvider implements ILinkProvider { +/** + * Configurable scheme link handler. + */ +interface ISchemeHandler { + matcher: RegExp; + opener: (event: MouseEvent, text: string) => void; +} - constructor( - private readonly _terminal: Terminal, - private readonly _urlMap: {[key: number]: string} - ) {} +interface IUrlWithHandler { + url: string; + schemeHandler: ISchemeHandler; +} - public provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void { - const pos = {x: position.x - 1, y: position.y - 1}; - // test whether we are above a cell with an urlId - // TODO: need API extension - const urlId = (this._terminal.buffer.active.getLine(pos.y)?.getCell(pos.x) as any).extended.urlId; - if (!urlId) { - callback(undefined); - return; - } +/** + * Default link handler. Opens the link in a new browser tab. + * Used by the default scheme handlers for http, https and ftp. + */ +function handleLink(event: MouseEvent, uri: string): void { + const newWindow = window.open(); + if (newWindow) { + newWindow.opener = null; + newWindow.location.href = uri; + } else { + console.warn('Opening link blocked as opener could not be cleared'); + } +} - // we got an url cell, thus we need to fetch whole viewport and - // mark all cells with that particular urlId - const yOffset = this._terminal.buffer.active.viewportY; - const ranges: IBufferRange[] = []; - let r: IBufferRange | null = null; - for (let y = yOffset; y < this._terminal.rows + yOffset; ++y) { - const line = this._terminal.buffer.active.getLine(y); - if (!line) { - break; - } - for (let x = 0; x < this._terminal.rows; ++x) { - const cell = line.getCell(x); - if (!cell) { - break; - } - if ((cell as any).extended.urlId === urlId) { - if (!r) { - r = {start: {x, y}, end: {x: x + 1, y}}; - } else { - r.end.x = x + 1; - r.end.y = y; - } - } else { - if (r) { - r.end.x = x; - r.end.y = y; - ranges.push(r); - r = null; - } - } - } - } - if (r) { - ranges.push(r); - } - // fix ranges to 1-based, right inclusive - for (const r of ranges) { - r.start.x++; - r.start.y++; - r.end.y++; - } +/** + * Some default scheme handlers. Handled in-browser. + */ +export const DEFAULT_SCHEMES = { + HTTP: {matcher: new RegExp('^http://.*'), opener: handleLink}, + HTTPS: {matcher: new RegExp('^http://.*'), opener: handleLink}, + FTP: {matcher: new RegExp('^ftp://.*'), opener: handleLink} +}; - // TODO: make this customizable from outside - callback({ - ranges, - text: this._urlMap[urlId], - decorations: {pointerCursor: true, underline: true}, - activate: (event: MouseEvent, text: string) => { - console.log('would open:', text); - }, - hover: (event: MouseEvent, text: string) => { - console.log('tooltip to show:', text); - } - }); - } -} export class HyperlinksAddon implements ITerminalAddon { private _oscHandler: IDisposable | undefined; private _linkProvider: IDisposable | undefined; private _internalId = 1; private _idMap: {[key: string]: number} = {}; - private _urlMap: {[key: number]: string} = {}; + private _urlMap: {[key: number]: IUrlWithHandler} = {}; + private _schemes: ISchemeHandler[] = []; /** * Parse params part of the data. @@ -125,14 +89,27 @@ export class HyperlinksAddon implements ITerminalAddon { } /** - * Method to filter for allowed URL schemes. - * Returns empty string if the given url does not pass the test. + * Method to filter allowed URL schemes. + * Returns the url with the matching handler, or nothing. */ - private _filterUrl(urlString: string): string { - // TODO: implement filter rules - return urlString; + private _filterUrl(urlString: string): IUrlWithHandler | void { + if (!urlString) { + return; + } + for (const schemeHandler of this._schemes) { + const m = urlString.match(schemeHandler.matcher); + if (m) { + return {url: m[0], schemeHandler}; + } + } + return; } + /** + * Update the terminal cell attributes. + * `hoverId` is used to identify cells of the same link. + * A `hoverId` of 0 disables any link handling of a cell (default). + */ private _updateAttrs(terminal: Terminal, hoverId: number = 0): void { // hack: remove url notion from extended attrs by private access // TODO: API to contribute to extended attributes @@ -143,41 +120,53 @@ export class HyperlinksAddon implements ITerminalAddon { } public activate(terminal: Terminal): void { + // register the OSC 8 sequence handler this._oscHandler = terminal.parser.registerOscHandler(8, data => { - + // always reset URL notion / link handling in buffer + // This is a safety measure, any OSC 8 invocations (even malformed) + // are treated as an attempt to finalize a previous url start. this._updateAttrs(terminal); + // malformed, exit early if (data.indexOf(';') === -1) { return true; } + + // extract needed bits of the sequence: params (might hold an id), url const params = this._parseParams(data.slice(0, data.indexOf(';'))); - const url = this._filterUrl(data.slice(data.indexOf(';') + 1)); - console.log(params, url); + const urlData = this._filterUrl(data.slice(data.indexOf(';') + 1)); - if (!url) { + // OSC 8 ; ; ST - official URL anchor end marker + if (!urlData) { return true; } + // OSC 8 ; [id=value] ; url ST - official URL anchor starter + let hoverId; if (params && params.id) { - // check whether we already know that id and url + // an id was given, thus try to match with earlier sequences + // we only consider full equality (id && url) as match, + // as the id might get reused by a later program with different url const oldInternal = this._idMap[params.id]; - if (oldInternal && this._urlMap[oldInternal] === url) { + if (oldInternal && this._urlMap[oldInternal].url === urlData.url) { hoverId = oldInternal; } else { hoverId = this._internalId++; this._idMap[params.id] = hoverId; - this._urlMap[hoverId] = url; + this._urlMap[hoverId] = urlData; } } else { hoverId = this._internalId++; - this._urlMap[hoverId] = url; + this._urlMap[hoverId] = urlData; } + // update extended cell attributes with hoverId this._updateAttrs(terminal, hoverId); - return true; }); + + // register a linkprovider to handle the extended attribute set by the sequence handler this._linkProvider = terminal.registerLinkProvider(new HyperlinkProvider(terminal, this._urlMap)); } @@ -186,5 +175,98 @@ export class HyperlinksAddon implements ITerminalAddon { this._linkProvider?.dispose(); this._idMap = {}; this._urlMap = {}; + this._schemes.length = 0; + } + + /** + * Register a scheme handler for the hyperlinks addon. + */ + public registerSchemeHandler(exe: ISchemeHandler): IDisposable { + const schemes = this._schemes; + if (schemes.indexOf(exe) === -1) { + schemes.push(exe); + } + return { + dispose: () => { + if (schemes.indexOf(exe) !== -1) { + schemes.splice(schemes.indexOf(exe), 1); + } + } + }; + } +} + + +class HyperlinkProvider implements ILinkProvider { + constructor( + private readonly _terminal: Terminal, + private readonly _urlMap: {[key: number]: IUrlWithHandler} + ) {} + + public provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void { + // fix position to 0-based right exclusive + const pos = {x: position.x - 1, y: position.y - 1}; + + // test whether the pointer is over a cell with an urlId + // also test that we actually have an url stored for the id + // TODO: need API extension + const urlId = (this._terminal.buffer.active.getLine(pos.y)?.getCell(pos.x) as any).extended.urlId; + if (!urlId || !this._urlMap[urlId]) { + callback(undefined); + return; + } + + // walk all viewport cells and collect cells with the same urlId in buffer ranges + const yOffset = this._terminal.buffer.active.viewportY; + const ranges: IBufferRange[] = []; + let r: IBufferRange | null = null; + for (let y = yOffset; y < this._terminal.rows + yOffset; ++y) { + const line = this._terminal.buffer.active.getLine(y); + if (!line) { + break; + } + for (let x = 0; x < this._terminal.rows; ++x) { + const cell = line.getCell(x); + if (!cell) { + break; + } + if ((cell as any).extended.urlId === urlId) { + if (!r) { + r = {start: {x, y}, end: {x: x + 1, y}}; + } else { + r.end.x = x + 1; + r.end.y = y; + } + } else { + if (r) { + r.end.x = x; + r.end.y = y; + ranges.push(r); + r = null; + } + } + } + } + if (r) { + ranges.push(r); + } + + // fix ranges to 1-based, right inclusive + for (const r of ranges) { + r.start.x++; + r.start.y++; + r.end.y++; + } + + // TODO: make this better customizable from outside + callback({ + ranges, + text: this._urlMap[urlId].url, + decorations: {pointerCursor: true, underline: true}, + activate: this._urlMap[urlId].schemeHandler.opener, + hover: (event: MouseEvent, text: string) => { + console.log('tooltip to show:', text); + } + }); } } diff --git a/addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts b/addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts index 18d6fbfb4f..07acd01500 100644 --- a/addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts +++ b/addons/xterm-addon-hyperlinks/typings/xterm-addon-hyperlinks.d.ts @@ -3,12 +3,18 @@ * @license MIT */ -import { Terminal, ITerminalAddon } from 'xterm'; +import { Terminal, ITerminalAddon, IDisposable } from 'xterm'; + +export interface ISchemeHandler { + matcher: RegExp; + opener: (event: MouseEvent, text: string) => void +} declare module 'xterm-addon-hyperlinks' { export class HyperlinksAddon implements ITerminalAddon { constructor(); public activate(terminal: Terminal): void; public dispose(): void; + public registerSchemeHandler(exe: ISchemeHandler): IDisposable; } } diff --git a/demo/client.ts b/demo/client.ts index d7475531a7..da400a7f8c 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -11,7 +11,7 @@ import { Terminal } from '../out/browser/public/Terminal'; import { AttachAddon } from '../addons/xterm-addon-attach/out/AttachAddon'; import { FitAddon } from '../addons/xterm-addon-fit/out/FitAddon'; -import { HyperlinksAddon } from '../addons/xterm-addon-hyperlinks/out/HyperlinksAddon'; +import { HyperlinksAddon, DEFAULT_SCHEMES } from '../addons/xterm-addon-hyperlinks/out/HyperlinksAddon'; import { SearchAddon, ISearchOptions } from '../addons/xterm-addon-search/out/SearchAddon'; import { SerializeAddon } from '../addons/xterm-addon-serialize/out/SerializeAddon'; import { WebLinksAddon } from '../addons/xterm-addon-web-links/out/WebLinksAddon'; @@ -165,6 +165,9 @@ function createTerminal(): void { addons.serialize.instance = new SerializeAddon(); addons.fit.instance = new FitAddon(); addons.hyperlinks.instance = new HyperlinksAddon(); + addons.hyperlinks.instance.registerSchemeHandler(DEFAULT_SCHEMES.HTTP); + addons.hyperlinks.instance.registerSchemeHandler(DEFAULT_SCHEMES.HTTPS); + addons.hyperlinks.instance.registerSchemeHandler(DEFAULT_SCHEMES.FTP); addons.unicode11.instance = new Unicode11Addon(); // TODO: Remove arguments when link provider API is the default addons['web-links'].instance = new WebLinksAddon(undefined, undefined, true); @@ -376,6 +379,11 @@ function initAddons(term: TerminalType): void { if (checkbox.checked) { addon.instance = new addon.ctor(); term.loadAddon(addon.instance); + if (name === 'hyperlinks') { + addon.instance.registerSchemeHandler(DEFAULT_SCHEMES.HTTP); + addon.instance.registerSchemeHandler(DEFAULT_SCHEMES.HTTPS); + addon.instance.registerSchemeHandler(DEFAULT_SCHEMES.FTP); + } if (name === 'webgl') { setTimeout(() => { document.body.appendChild((addon.instance as WebglAddon).textureAtlas); From c5299d56e8bfe569dc2fdb711908f8fe6ebcfb93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 5 May 2020 01:20:20 +0200 Subject: [PATCH 4/4] use Map instead of objects, limits applied --- .../src/HyperlinksAddon.ts | 60 ++++++++++++++----- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts index 58d6e51d60..1e06f188f3 100644 --- a/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts +++ b/addons/xterm-addon-hyperlinks/src/HyperlinksAddon.ts @@ -54,14 +54,29 @@ export const DEFAULT_SCHEMES = { }; +// limit stored URLs to avoid OOM +// cached by the addon (FIFO): limit <= n <= limit * 2 +const CACHE_LIMIT = 500; +// values taken from spec as used by VTE +const MAX_URL_LENGTH = 2083; +const MAX_ID_LENGTH = 250; + + export class HyperlinksAddon implements ITerminalAddon { private _oscHandler: IDisposable | undefined; private _linkProvider: IDisposable | undefined; private _internalId = 1; - private _idMap: {[key: string]: number} = {}; - private _urlMap: {[key: number]: IUrlWithHandler} = {}; + private _lowestId = 1; + private _idMap: Map = new Map(); + private _urlMap: Map = new Map(); private _schemes: ISchemeHandler[] = []; + constructor( + public cacheLimit: number = CACHE_LIMIT, + public maxUrlLength: number = MAX_URL_LENGTH, + public maxIdLength: number = MAX_ID_LENGTH + ) {} + /** * Parse params part of the data. * Converts into a key-value mapping object if: @@ -80,7 +95,7 @@ export class HyperlinksAddon implements ITerminalAddon { return; } const [key, value] = p; - if (!key || !value) { + if (!key || !value || value.length > this.maxIdLength) { return; } result[key] = value; @@ -93,7 +108,7 @@ export class HyperlinksAddon implements ITerminalAddon { * Returns the url with the matching handler, or nothing. */ private _filterUrl(urlString: string): IUrlWithHandler | void { - if (!urlString) { + if (!urlString || urlString.length > this.maxUrlLength) { return; } for (const schemeHandler of this._schemes) { @@ -119,6 +134,18 @@ export class HyperlinksAddon implements ITerminalAddon { attr.updateExtended(); } + private _limitCache(): void { + if (this._internalId - this._lowestId > this.cacheLimit * 2) { + this._lowestId = this._internalId - this.cacheLimit; + [...this._urlMap.keys()] + .filter(key => key < this._lowestId) + .forEach(key => this._urlMap.delete(key)); + [...this._idMap.entries()] + .filter(([unused, value]) => value < this._lowestId) + .forEach(([key, unused]) => this._idMap.delete(key)); + } + } + public activate(terminal: Terminal): void { // register the OSC 8 sequence handler this._oscHandler = terminal.parser.registerOscHandler(8, data => { @@ -148,19 +175,22 @@ export class HyperlinksAddon implements ITerminalAddon { // an id was given, thus try to match with earlier sequences // we only consider full equality (id && url) as match, // as the id might get reused by a later program with different url - const oldInternal = this._idMap[params.id]; - if (oldInternal && this._urlMap[oldInternal].url === urlData.url) { + const oldInternal = this._idMap.get(params.id); + if (oldInternal && this._urlMap.get(oldInternal)?.url === urlData.url) { hoverId = oldInternal; } else { hoverId = this._internalId++; - this._idMap[params.id] = hoverId; - this._urlMap[hoverId] = urlData; + this._idMap.set(params.id, hoverId); + this._urlMap.set(hoverId, urlData); } } else { hoverId = this._internalId++; - this._urlMap[hoverId] = urlData; + this._urlMap.set(hoverId, urlData); } + // cleanup maps + this._limitCache(); + // update extended cell attributes with hoverId this._updateAttrs(terminal, hoverId); return true; @@ -173,8 +203,8 @@ export class HyperlinksAddon implements ITerminalAddon { public dispose(): void { this._oscHandler?.dispose(); this._linkProvider?.dispose(); - this._idMap = {}; - this._urlMap = {}; + this._idMap.clear(); + this._urlMap.clear(); this._schemes.length = 0; } @@ -200,7 +230,7 @@ export class HyperlinksAddon implements ITerminalAddon { class HyperlinkProvider implements ILinkProvider { constructor( private readonly _terminal: Terminal, - private readonly _urlMap: {[key: number]: IUrlWithHandler} + private readonly _urlMap: Map ) {} public provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void { @@ -211,7 +241,7 @@ class HyperlinkProvider implements ILinkProvider { // also test that we actually have an url stored for the id // TODO: need API extension const urlId = (this._terminal.buffer.active.getLine(pos.y)?.getCell(pos.x) as any).extended.urlId; - if (!urlId || !this._urlMap[urlId]) { + if (!urlId || !this._urlMap.get(urlId)) { callback(undefined); return; } @@ -261,9 +291,9 @@ class HyperlinkProvider implements ILinkProvider { // TODO: make this better customizable from outside callback({ ranges, - text: this._urlMap[urlId].url, + text: this._urlMap.get(urlId)!.url, decorations: {pointerCursor: true, underline: true}, - activate: this._urlMap[urlId].schemeHandler.opener, + activate: this._urlMap.get(urlId)!.schemeHandler.opener, hover: (event: MouseEvent, text: string) => { console.log('tooltip to show:', text); }