Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic link parser for better link detection #2530

Merged
merged 31 commits into from
Feb 8, 2020
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0c5962c
Added support for a basic link parser
jmbockhorst Nov 1, 2019
3e2e4ac
Removed file detection from WebLinkProvider
jmbockhorst Nov 1, 2019
1e877a0
Fix lint errors
jmbockhorst Nov 1, 2019
cf082cc
Fix links when viewport is scrolled
jmbockhorst Nov 1, 2019
ec78fc7
Remove usage of ! operator
jmbockhorst Nov 1, 2019
e57cb8b
Use regex parser for web links
jmbockhorst Nov 1, 2019
fdfadfe
Fixed link caching errors
jmbockhorst Nov 5, 2019
28c43ac
Remove link caching
jmbockhorst Nov 5, 2019
b3d95d2
Merge branch 'master' of https://github.com/xtermjs/xterm.js into lin…
jmbockhorst Nov 5, 2019
ac6c8a5
Support multiline links in the WebLinksAddon
jmbockhorst Nov 6, 2019
b07a6d1
Add test for multiline links
jmbockhorst Nov 6, 2019
f18a6fe
Fixed bug in link detection for wrapped links
jmbockhorst Nov 7, 2019
700c4ac
Rework link parser to use a single cached link and make other request…
jmbockhorst Nov 8, 2019
f93939e
Merge remote-tracking branch 'ups/master' into pr/jmbockhorst/2530
Tyriar Nov 8, 2019
8924aa7
Use onRender() instead of onData() and onScroll()
jmbockhorst Nov 8, 2019
a190634
Only clear link if it was in the range of onRender and fix tests
jmbockhorst Nov 8, 2019
a95b1b4
Merge branch 'master' of https://github.com/xtermjs/xterm.js into lin…
jmbockhorst Feb 4, 2020
e252b4b
Merge branch 'master' into linkParser
Tyriar Feb 7, 2020
a066bbe
:lipstick:
Tyriar Feb 7, 2020
ef071ac
Polish API
Tyriar Feb 7, 2020
c327116
url -> text
Tyriar Feb 7, 2020
b18ded0
Rename tooltip to hover/leave
Tyriar Feb 7, 2020
f4a3125
handle -> activate
Tyriar Feb 7, 2020
0103c68
Start registerLinkProvider tests
Tyriar Feb 7, 2020
7f1f221
Add test for activate
Tyriar Feb 7, 2020
e4875f2
Add test for absense of hover/leave
Tyriar Feb 7, 2020
660cf26
Merge branch 'master' into linkParser
Tyriar Feb 7, 2020
54743ed
Make link matcher API the default for web links addon
Tyriar Feb 8, 2020
a7c2d54
Document link provider argument in web links addon
Tyriar Feb 8, 2020
0e302ba
Mark link matcher API as deprecated
Tyriar Feb 8, 2020
d1d27b5
Remove logs
Tyriar Feb 8, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions addons/xterm-addon-web-links/src/WebLinkProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/

import { ILinkProvider, IBufferCellPosition, ILink, Terminal, IBuffer } from 'xterm';
Tyriar marked this conversation as resolved.
Show resolved Hide resolved

export class WebLinkProvider implements ILinkProvider {

constructor(
private readonly _terminal: Terminal,
private readonly _regex: RegExp,
private readonly _handler: (event: MouseEvent, uri: string) => void
) {

}

provideLink(position: IBufferCellPosition, callback: (link: ILink | undefined) => void): void {
callback(LinkComputer.computeLink(position, this._regex, this._terminal, this._handler));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently any mousemove will trigger provideLink, it should only fire as a minimum when the mouse is moved off of the current cell:

Screen Shot 2019-11-07 at 11 23 43 AM

Ideally we would cache just the current link and only fire provideLink when the cursor moved off of the current link's range. We could clear out the current link by listening to onData and onScroll:

// Register listeners and dispose them immediately after either of them are fired
this._linkCacheDisposables.push(this._coreService.onData(() => this._clearCurrentLink()));
this._linkCacheDisposables.push(this._onScroll(() => this._clearCurrentLink()));

...

private _clearCurrentLink() {
  this._currentLink = undefined;
  this._linkCacheDisposables.forEach(l => l.dispose());
  this._linkCacheDisposables = [];
}));

onScroll is currently owned by src/Terminal.ts but it will eventually move into common/ and be available on a service, in the meantime I suggest just passing it into Linkifier2 via the constructor much like we're doing for this:

// TODO: Move this into a service
private readonly _scrollToBottom: () => void,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like scroll events don't get fired when the viewport is scrolled, only when a new line is added. Ref:

this._scrollLines(diff, true);

Changing the second parameter to false gives what we what, but I'm sure it breaks something elsewhere.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing that to false will break a bunch of other stuff... Hmm I thought there was an event for when scroll happens but I guess not. I think what we're actually after is this one:

onRender: IEvent<{ start: number, end: number }>;

This fires immediately after lines in the range start to end have been re-rendered. We could cache the current mouse position from the mousemove event and then provide links for it in the onRender listener?

}
}

export class LinkComputer {
public static computeLink(position: IBufferCellPosition, regex: RegExp, terminal: Terminal, handle: (event: MouseEvent, uri: string) => void): ILink | undefined {
const rex = new RegExp(regex.source, (regex.flags || '') + 'g');

const [line, startLineIndex] = LinkComputer._translateBufferLineToStringWithWrap(position.y - 1, false, terminal);

let match;
let stringIndex = -1;

while ((match = rex.exec(line)) !== null) {
const url = match[1];
if (!url) {
// something matched but does not comply with the given matchIndex
// since this is most likely a bug the regex itself we simply do nothing here
console.log('match found without corresponding matchIndex');
break;
}

// Get index, match.index is for the outer match which includes negated chars
// therefore we cannot use match.index directly, instead we search the position
// of the match group in text again
// also correct regex and string search offsets for the next loop run
stringIndex = line.indexOf(url, stringIndex + 1);
rex.lastIndex = stringIndex + url.length;
if (stringIndex < 0) {
// invalid stringIndex (should not have happened)
break;
}

let endX = stringIndex + url.length + 1;
let endY = startLineIndex + 1;

while (endX > terminal.cols) {
endX -= terminal.cols;
endY++;
}

const range = {
start: {
x: stringIndex + 1,
y: startLineIndex + 1
},
end: {
x: endX,
y: endY
}
};

return { range, url, handle };
}
}

/**
* Gets the entire line for the buffer line
* @param line The line being translated.
* @param trimRight Whether to trim whitespace to the right.
* @param terminal The terminal
*/
private static _translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean, terminal: Terminal): [string, number] {
let lineString = '';
let lineWrapsToNext: boolean;
let prevLinesToWrap: boolean;

do {
const line = terminal.buffer.getLine(lineIndex);
if (!line) {
break;
}

if (line.isWrapped) {
lineIndex--;
}

prevLinesToWrap = line.isWrapped;
} while (prevLinesToWrap);

const startLineIndex = lineIndex;

do {
const nextLine = terminal.buffer.getLine(lineIndex + 1);
lineWrapsToNext = nextLine ? nextLine.isWrapped : false;
const line = terminal.buffer.getLine(lineIndex);
if (!line) {
break;
}
lineString += line.translateToString(!lineWrapsToNext && trimRight).substring(0, terminal.cols);
lineIndex++;
} while (lineWrapsToNext);

return [lineString, startLineIndex];
}
}
14 changes: 8 additions & 6 deletions addons/xterm-addon-web-links/src/WebLinksAddon.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const width = 800;
const height = 600;

describe('WebLinksAddon', () => {
before(async function(): Promise<any> {
before(async function (): Promise<any> {
this.timeout(10000);
browser = await puppeteer.launch({
headless: process.argv.indexOf('--headless') !== -1,
Expand All @@ -30,29 +30,29 @@ describe('WebLinksAddon', () => {
await browser.close();
});

beforeEach(async function(): Promise<any> {
beforeEach(async function (): Promise<any> {
this.timeout(5000);
await page.goto(APP);
});

it('.com', async function(): Promise<any> {
it('.com', async function (): Promise<any> {
this.timeout(20000);
await testHostName('foo.com');
});

it('.com.au', async function(): Promise<any> {
it('.com.au', async function (): Promise<any> {
this.timeout(20000);
await testHostName('foo.com.au');
});

it('.io', async function(): Promise<any> {
it('.io', async function (): Promise<any> {
this.timeout(20000);
await testHostName('foo.io');
});
});

async function testHostName(hostname: string): Promise<void> {
await openTerminal({ rendererType: 'dom' });
await openTerminal({ rendererType: 'dom', cols: 40 });
await page.evaluate(`window.term.loadAddon(new window.WebLinksAddon())`);
await page.evaluate(`
window.term.writeln(' http://${hostname} ');
Expand All @@ -62,6 +62,7 @@ async function testHostName(hostname: string): Promise<void> {
window.term.writeln('"http://${hostname}/"');
window.term.writeln('\\'http://${hostname}/\\'');
window.term.writeln('http://${hostname}/subpath/+/id');
window.term.writeln('http://${hostname}/subpath/subpath2/subpath3/subpath4/subpath5/+/id');
`);
assert.equal(await getLinkAtCell(3, 1), `http://${hostname}`);
assert.equal(await getLinkAtCell(3, 2), `http://${hostname}/a~b#c~d?e~f`);
Expand All @@ -70,6 +71,7 @@ async function testHostName(hostname: string): Promise<void> {
assert.equal(await getLinkAtCell(2, 5), `http://${hostname}/`);
assert.equal(await getLinkAtCell(2, 6), `http://${hostname}/`);
assert.equal(await getLinkAtCell(1, 7), `http://${hostname}/subpath/+/id`);
assert.equal(await getLinkAtCell(1, 8) + await getLinkAtCell(1, 9), `http://${hostname}/subpath/subpath2/subpath3/subpath4/subpath5/+/id`);
}

async function openTerminal(options: ITerminalOptions = {}): Promise<void> {
Expand Down
17 changes: 14 additions & 3 deletions addons/xterm-addon-web-links/src/WebLinksAddon.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/

import { Terminal, ILinkMatcherOptions, ITerminalAddon } from 'xterm';
import { Terminal, ILinkMatcherOptions, ITerminalAddon, ILinkProvider, IDisposable } from 'xterm';
import { WebLinkProvider } from './WebLinkProvider';

const protocolClause = '(https?:\\/\\/)';
const domainCharacterSet = '[\\da-z\\.-]+';
Expand Down Expand Up @@ -32,6 +33,7 @@ function handleLink(event: MouseEvent, uri: string): void {
export class WebLinksAddon implements ITerminalAddon {
private _linkMatcherId: number | undefined;
private _terminal: Terminal | undefined;
private _linkProvider: IDisposable | undefined;

constructor(
private _handler: (event: MouseEvent, uri: string) => void = handleLink,
Expand All @@ -42,12 +44,21 @@ export class WebLinksAddon implements ITerminalAddon {

public activate(terminal: Terminal): void {
this._terminal = terminal;
this._linkMatcherId = this._terminal.registerLinkMatcher(strictUrlRegex, this._handler, this._options);

if ('registerLinkProvider' in this._terminal as any) {
this._linkProvider = this._terminal.registerLinkProvider(new WebLinkProvider(this._terminal, strictUrlRegex, this._handler));
} else {
this._linkMatcherId = this._terminal.registerLinkMatcher(strictUrlRegex, this._handler, this._options);
}
Tyriar marked this conversation as resolved.
Show resolved Hide resolved
}

public dispose(): void {
if (this._linkMatcherId !== undefined && this._terminal !== undefined) {
this._terminal.deregisterLinkMatcher(this._linkMatcherId);
}

if (this._linkProvider) {
this._linkProvider.dispose();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New syntax for this as of a couple of days ago (TS 3.7):

this._linkProvider?.dispose();

}
}
3 changes: 3 additions & 0 deletions addons/xterm-addon-webgl/src/renderLayer/LinkRenderLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export class LinkRenderLayer extends BaseRenderLayer {
super(container, 'link', zIndex, true, colors);
terminal.linkifier.onLinkHover(e => this._onLinkHover(e));
terminal.linkifier.onLinkLeave(e => this._onLinkLeave(e));

terminal.linkifier2.onShowTooltip(e => this._onLinkHover(e));
terminal.linkifier2.onHideTooltip(e => this._onLinkLeave(e));
}

public resize(terminal: Terminal, dim: IRenderDimensions): void {
Expand Down
26 changes: 17 additions & 9 deletions src/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import * as Strings from 'browser/LocalizableStrings';
import { SoundService } from 'browser/services/SoundService';
import { MouseZoneManager } from 'browser/MouseZoneManager';
import { AccessibilityManager } from './AccessibilityManager';
import { ITheme, IMarker, IDisposable, ISelectionPosition } from 'xterm';
import { ITheme, IMarker, IDisposable, ISelectionPosition, ILinkProvider } from 'xterm';
import { DomRenderer } from './renderer/dom/DomRenderer';
import { IKeyboardEvent, KeyboardResultType, ICharset, IBufferLine, IAttributeData, CoreMouseEventType, CoreMouseButton, CoreMouseAction } from 'common/Types';
import { evaluateKeyboardEvent } from 'common/input/Keyboard';
Expand All @@ -58,11 +58,12 @@ import { MouseService } from 'browser/services/MouseService';
import { IParams, IFunctionIdentifier } from 'common/parser/Types';
import { CoreService } from 'common/services/CoreService';
import { LogService } from 'common/services/LogService';
import { ILinkifier, IMouseZoneManager, LinkMatcherHandler, ILinkMatcherOptions, IViewport } from 'browser/Types';
import { ILinkifier, IMouseZoneManager, LinkMatcherHandler, ILinkMatcherOptions, IViewport, ILinkifier2 } from 'browser/Types';
import { DirtyRowService } from 'common/services/DirtyRowService';
import { InstantiationService } from 'common/services/InstantiationService';
import { CoreMouseService } from 'common/services/CoreMouseService';
import { WriteBuffer } from 'common/input/WriteBuffer';
import { Linkifier2 } from 'browser/Linkifier2';

// Let it work inside Node.js for automated testing purposes.
const document = (typeof window !== 'undefined') ? window.document : null;
Expand Down Expand Up @@ -154,6 +155,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp

private _inputHandler: InputHandler;
public linkifier: ILinkifier;
public linkifier2: ILinkifier2;
public viewport: IViewport;
private _compositionHelper: ICompositionHelper;
private _mouseZoneManager: IMouseZoneManager;
Expand Down Expand Up @@ -284,6 +286,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp
this.register(this._inputHandler);

this.linkifier = this.linkifier || new Linkifier(this._bufferService, this._logService);
this.linkifier2 = this.linkifier2 || new Linkifier2(this._bufferService, this._coreService);

if (this.options.windowsMode) {
this._windowsMode = applyWindowsMode(this);
Expand Down Expand Up @@ -603,6 +606,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp
this.register(this._mouseZoneManager);
this.register(this.onScroll(() => this._mouseZoneManager.clearAll()));
this.linkifier.attachToDom(this.element, this._mouseZoneManager);
this.linkifier2.attachToDom(this.element, this._viewportElement, this._mouseService);

// This event listener must be registered aftre MouseZoneManager is created
this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService.onMouseDown(e)));
Expand Down Expand Up @@ -697,8 +701,8 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp
} else {
// according to MDN buttons only reports up to button 5 (AUX2)
but = ev.buttons & 1 ? CoreMouseButton.LEFT :
ev.buttons & 4 ? CoreMouseButton.MIDDLE :
ev.buttons & 2 ? CoreMouseButton.RIGHT :
ev.buttons & 4 ? CoreMouseButton.MIDDLE :
ev.buttons & 2 ? CoreMouseButton.RIGHT :
CoreMouseButton.NONE; // fallback to NONE
}
break;
Expand Down Expand Up @@ -747,13 +751,13 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp
* Note: 'mousedown' currently is "always on" and not managed
* by onProtocolChange.
*/
const requestedEvents: {[key: string]: ((ev: Event) => void) | null} = {
const requestedEvents: { [key: string]: ((ev: Event) => void) | null } = {
mouseup: null,
wheel: null,
mousedrag: null,
mousemove: null
};
const eventListeners: {[key: string]: (ev: Event) => void} = {
const eventListeners: { [key: string]: (ev: Event) => void } = {
mouseup: (ev: MouseEvent) => {
sendEvent(ev);
if (!ev.buttons) {
Expand Down Expand Up @@ -876,7 +880,7 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp
}

// Construct and send sequences
const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + ( ev.deltaY < 0 ? 'A' : 'B');
const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B');
let data = '';
for (let i = 0; i < Math.abs(amount); i++) {
data += sequence;
Expand Down Expand Up @@ -1140,6 +1144,10 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp
}
}

public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
return this.linkifier2.registerLinkProvider(linkProvider);
}

public registerCharacterJoiner(handler: CharacterJoinerHandler): number {
const joinerId = this._renderService.registerCharacterJoiner(handler);
this.refresh(0, this.rows - 1);
Expand Down Expand Up @@ -1292,8 +1300,8 @@ export class Terminal extends Disposable implements ITerminal, IDisposable, IInp

private _isThirdLevelShift(browser: IBrowser, ev: IKeyboardEvent): boolean {
const thirdLevelKey =
(browser.isMac && !this.options.macOptionIsMeta && ev.altKey && !ev.ctrlKey && !ev.metaKey) ||
(browser.isWindows && ev.altKey && ev.ctrlKey && !ev.metaKey);
(browser.isMac && !this.options.macOptionIsMeta && ev.altKey && !ev.ctrlKey && !ev.metaKey) ||
(browser.isWindows && ev.altKey && ev.ctrlKey && !ev.metaKey);

if (ev.type === 'keypress') {
return thirdLevelKey;
Expand Down
28 changes: 16 additions & 12 deletions src/TestUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import { IBuffer, IBufferStringIterator, IBufferSet } from 'common/buffer/Types'
import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, ICharset, CoreMouseEventType } from 'common/Types';
import { Buffer } from 'common/buffer/Buffer';
import * as Browser from 'common/Platform';
import { IDisposable, IMarker, IEvent, ISelectionPosition } from 'xterm';
import { IDisposable, IMarker, IEvent, ISelectionPosition, ILinkProvider } from 'xterm';
import { Terminal } from './Terminal';
import { AttributeData } from 'common/buffer/AttributeData';
import { IColorManager, IColorSet, ILinkMatcherOptions, ILinkifier, IViewport } from 'browser/Types';
import { IColorManager, IColorSet, ILinkMatcherOptions, ILinkifier, IViewport, ILinkifier2 } from 'browser/Types';
import { IOptionsService } from 'common/services/Services';
import { EventEmitter } from 'common/EventEmitter';
import { IParams, IFunctionIdentifier } from 'common/parser/Types';
Expand Down Expand Up @@ -91,6 +91,9 @@ export class MockTerminal implements ITerminal {
deregisterLinkMatcher(matcherId: number): void {
throw new Error('Method not implemented.');
}
registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
throw new Error('Method not implemented.');
}
hasSelection(): boolean {
throw new Error('Method not implemented.');
}
Expand Down Expand Up @@ -133,6 +136,7 @@ export class MockTerminal implements ITerminal {
bracketedPasteMode: boolean;
renderer: IRenderer;
linkifier: ILinkifier;
linkifier2: ILinkifier2;
isFocused: boolean;
options: ITerminalOptions = {};
element: HTMLElement;
Expand Down Expand Up @@ -384,16 +388,16 @@ export class MockRenderer implements IRenderer {
setColors(colors: IColorSet): void {
throw new Error('Method not implemented.');
}
onResize(cols: number, rows: number): void {}
onCharSizeChanged(): void {}
onBlur(): void {}
onFocus(): void {}
onSelectionChanged(start: [number, number], end: [number, number]): void {}
onCursorMove(): void {}
onOptionsChanged(): void {}
onDevicePixelRatioChange(): void {}
clear(): void {}
renderRows(start: number, end: number): void {}
onResize(cols: number, rows: number): void { }
onCharSizeChanged(): void { }
onBlur(): void { }
onFocus(): void { }
onSelectionChanged(start: [number, number], end: [number, number]): void { }
onCursorMove(): void { }
onOptionsChanged(): void { }
onDevicePixelRatioChange(): void { }
clear(): void { }
renderRows(start: number, end: number): void { }
registerCharacterJoiner(handler: CharacterJoinerHandler): number { return 0; }
deregisterCharacterJoiner(): boolean { return true; }
}
Expand Down
Loading