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

Pull web link support into an addon #1298

Merged
merged 4 commits into from
Mar 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions demo/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import * as attach from '../build/addons/attach/attach';
import * as fit from '../build/addons/fit/fit';
import * as fullscreen from '../build/addons/fullscreen/fullscreen';
import * as search from '../build/addons/search/search';
import * as webLinks from '../build/addons/webLinks/webLinks';
import * as winptyCompat from '../build/addons/winptyCompat/winptyCompat';


Terminal.applyAddon(attach);
Terminal.applyAddon(fit);
Terminal.applyAddon(fullscreen);
Terminal.applyAddon(search);
Terminal.applyAddon(webLinks);
Terminal.applyAddon(winptyCompat);


Expand Down Expand Up @@ -121,6 +123,7 @@ function createTerminal() {

term.open(terminalContainer);
term.winptyCompatInit();
term.webLinksInit();
term.fit();
term.focus();

Expand Down
2 changes: 1 addition & 1 deletion gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const tsProject = ts.createProject('tsconfig.json');
const srcDir = tsProject.config.compilerOptions.rootDir;
let outDir = tsProject.config.compilerOptions.outDir;

const addons = ['attach', 'fit', 'fullscreen', 'search', 'terminado', 'winptyCompat', 'zmodem'];
const addons = fs.readdirSync(`${__dirname}/src/addons`);

// Under some environments like TravisCI, this comes out at absolute which can
// break the build. This ensures that the outDir is absolute.
Expand Down
12 changes: 3 additions & 9 deletions src/Linkifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,6 @@ describe('Linkifier', () => {
linkifier.attachToDom(mouseZoneManager);
});

describe('http links', () => {
it('should allow ~ character in URI path', (done) => {
assertLinkifiesEntireRow('http://foo.com/a~b#c~d?e~f', done);
});
});

describe('link matcher', () => {
it('should match a single link', done => {
assertLinkifiesRow('foo', /foo/, [{x: 0, length: 3}], done);
Expand Down Expand Up @@ -200,19 +194,19 @@ describe('Linkifier', () => {
it('should order the list from highest priority to lowest #1', () => {
const aId = linkifier.registerLinkMatcher(/a/, () => {}, { priority: 1 });
const bId = linkifier.registerLinkMatcher(/b/, () => {}, { priority: -1 });
assert.deepEqual(linkifier.linkMatchers.map(lm => lm.id), [aId, 0, bId]);
assert.deepEqual(linkifier.linkMatchers.map(lm => lm.id), [aId, bId]);
});

it('should order the list from highest priority to lowest #2', () => {
const aId = linkifier.registerLinkMatcher(/a/, () => {}, { priority: -1 });
const bId = linkifier.registerLinkMatcher(/b/, () => {}, { priority: 1 });
assert.deepEqual(linkifier.linkMatchers.map(lm => lm.id), [bId, 0, aId]);
assert.deepEqual(linkifier.linkMatchers.map(lm => lm.id), [bId, aId]);
});

it('should order items of equal priority in the order they are added', () => {
const aId = linkifier.registerLinkMatcher(/a/, () => {}, { priority: 0 });
const bId = linkifier.registerLinkMatcher(/b/, () => {}, { priority: 0 });
assert.deepEqual(linkifier.linkMatchers.map(lm => lm.id), [0, aId, bId]);
assert.deepEqual(linkifier.linkMatchers.map(lm => lm.id), [aId, bId]);
});
});
});
Expand Down
50 changes: 3 additions & 47 deletions src/Linkifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,6 @@ import { ILinkHoverEvent, ILinkMatcher, LinkMatcherHandler, LinkMatcherValidatio
import { MouseZone } from './input/MouseZoneManager';
import { EventEmitter } from './EventEmitter';

const protocolClause = '(https?:\\/\\/)';
const domainCharacterSet = '[\\da-z\\.-]+';
const negatedDomainCharacterSet = '[^\\da-z\\.-]+';
const domainBodyClause = '(' + domainCharacterSet + ')';
const tldClause = '([a-z\\.]{2,6})';
const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})';
const localHostClause = '(localhost)';
const portClause = '(:\\d{1,5})';
const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?';
const pathClause = '(\\/[\\/\\w\\.\\-%~]*)*';
const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;~\\=\\.\\-]*';
const queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?';
const hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?';
const negatedPathCharacterSet = '[^\\/\\w\\.\\-%]+';
const bodyClause = hostClause + pathClause + queryStringClause + hashFragmentClause;
const start = '(?:^|' + negatedDomainCharacterSet + ')(';
const end = ')($|' + negatedPathCharacterSet + ')';
const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end);

/**
* The ID of the built in http(s) link matcher.
*/
const HYPERTEXT_LINK_MATCHER_ID = 0;

/**
* The Linkifier applies links to rows shortly after they have been refreshed.
*/
Expand All @@ -47,7 +23,7 @@ export class Linkifier extends EventEmitter implements ILinkifier {

private _mouseZoneManager: IMouseZoneManager;
private _rowsTimeoutId: number;
private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID;
private _nextLinkMatcherId = 0;
private _rowsToLinkify: {start: number, end: number};

constructor(
Expand All @@ -58,7 +34,6 @@ export class Linkifier extends EventEmitter implements ILinkifier {
start: null,
end: null
};
this.registerLinkMatcher(strictUrlRegex, null, { matchIndex: 1 });
}

/**
Expand Down Expand Up @@ -111,23 +86,6 @@ export class Linkifier extends EventEmitter implements ILinkifier {
this._rowsToLinkify.end = null;
}

/**
* Attaches a handler for hypertext links, overriding default <a> behavior for
* tandard http(s) links.
* @param handler The handler to use, this can be cleared with null.
*/
public setHypertextLinkHandler(handler: LinkMatcherHandler): void {
this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler;
}

/**
* Attaches a validation callback for hypertext links.
* @param callback The callback to use, this can be cleared with null.
*/
public setHypertextValidationCallback(callback: LinkMatcherValidationCallback): void {
this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].validationCallback = callback;
}

/**
* Registers a link matcher, allowing custom link patterns to be matched and
* handled.
Expand All @@ -139,7 +97,7 @@ export class Linkifier extends EventEmitter implements ILinkifier {
* @return The ID of the new matcher, this can be used to deregister.
*/
public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: ILinkMatcherOptions = {}): number {
if (this._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) {
if (!handler) {
throw new Error('handler must be defined');
}
const matcher: ILinkMatcher = {
Expand Down Expand Up @@ -185,8 +143,7 @@ export class Linkifier extends EventEmitter implements ILinkifier {
* @return Whether a link matcher was found and deregistered.
*/
public deregisterLinkMatcher(matcherId: number): boolean {
// ID 0 is the hypertext link matcher which cannot be deregistered
for (let i = 1; i < this._linkMatchers.length; i++) {
for (let i = 0; i < this._linkMatchers.length; i++) {
if (this._linkMatchers[i].id === matcherId) {
this._linkMatchers.splice(i, 1);
return true;
Expand Down Expand Up @@ -222,7 +179,6 @@ export class Linkifier extends EventEmitter implements ILinkifier {
private _doLinkifyRow(rowIndex: number, text: string, matcher: ILinkMatcher, offset: number = 0): void {
// Iterate over nodes as we want to consider text nodes
let result = [];
const isHttpLinkMatcher = matcher.id === HYPERTEXT_LINK_MATCHER_ID;

// Find the first match
let match = text.match(matcher.regex);
Expand Down
45 changes: 5 additions & 40 deletions src/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1326,36 +1326,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
this.customKeyEventHandler = customKeyEventHandler;
}

/**
* Attaches a http(s) link handler, forcing web links to behave differently to
* regular <a> tags. This will trigger a refresh as links potentially need to be
* reconstructed. Calling this with null will remove the handler.
* @param handler The handler callback function.
*/
public setHypertextLinkHandler(handler: LinkMatcherHandler): void {
if (!this.linkifier) {
throw new Error('Cannot attach a hypertext link handler before Terminal.open is called');
}
this.linkifier.setHypertextLinkHandler(handler);
// Refresh to force links to refresh
this.refresh(0, this.rows - 1);
}

/**
* Attaches a validation callback for hypertext links. This is useful to use
* validation logic or to do something with the link's element and url.
* @param callback The callback to use, this can
* be cleared with null.
*/
public setHypertextValidationCallback(callback: LinkMatcherValidationCallback): void {
if (!this.linkifier) {
throw new Error('Cannot attach a hypertext validation callback before Terminal.open is called');
}
this.linkifier.setHypertextValidationCallback(callback);
// // Refresh to force links to refresh
this.refresh(0, this.rows - 1);
}

/**
* Registers a link matcher, allowing custom link patterns to be matched and
* handled.
Expand All @@ -1367,23 +1337,18 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT
* @return The ID of the new matcher, this can be used to deregister.
*/
public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options?: ILinkMatcherOptions): number {
if (this.linkifier) {
const matcherId = this.linkifier.registerLinkMatcher(regex, handler, options);
this.refresh(0, this.rows - 1);
return matcherId;
}
return 0;
const matcherId = this.linkifier.registerLinkMatcher(regex, handler, options);
this.refresh(0, this.rows - 1);
return matcherId;
}

/**
* Deregisters a link matcher if it has been registered.
* @param matcherId The link matcher's ID (returned after register)
*/
public deregisterLinkMatcher(matcherId: number): void {
if (this.linkifier) {
if (this.linkifier.deregisterLinkMatcher(matcherId)) {
this.refresh(0, this.rows - 1);
}
if (this.linkifier.deregisterLinkMatcher(matcherId)) {
this.refresh(0, this.rows - 1);
}
}

Expand Down
4 changes: 1 addition & 3 deletions src/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type XtermListener = (...args: any[]) => void;
export type CharData = [number, string, number, number];
export type LineData = CharData[];

export type LinkMatcherHandler = (event: MouseEvent, uri: string) => boolean | void;
export type LinkMatcherHandler = (event: MouseEvent, uri: string) => void;
export type LinkMatcherValidationCallback = (uri: string, callback: (isValid: boolean) => void) => void;

export enum LinkHoverEventTypes {
Expand Down Expand Up @@ -298,8 +298,6 @@ export interface ISelectionManager {
export interface ILinkifier extends IEventEmitter {
attachToDom(mouseZoneManager: IMouseZoneManager): void;
linkifyRows(start: number, end: number): void;
setHypertextLinkHandler(handler: LinkMatcherHandler): void;
setHypertextValidationCallback(callback: LinkMatcherValidationCallback): void;
registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options?: ILinkMatcherOptions): number;
deregisterLinkMatcher(matcherId: number): boolean;
}
Expand Down
5 changes: 5 additions & 0 deletions src/addons/webLinks/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "xterm.weblinks",
"main": "weblinks.js",
"private": true
}
11 changes: 11 additions & 0 deletions src/addons/webLinks/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"rootDir": ".",
"outDir": "../../../lib/addons/webLinks/",
"sourceMap": true,
"removeComments": true,
"declaration": true
}
}
42 changes: 42 additions & 0 deletions src/addons/webLinks/webLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/

import { assert, expect } from 'chai';

import * as webLinks from './webLinks';

class MockTerminal {
public regex: RegExp;
public handler: (event: MouseEvent, uri: string) => void;
public options?: any;

public registerLinkMatcher(regex: RegExp, handler: (event: MouseEvent, uri: string) => void, options?: any): number {
this.regex = regex;
this.handler = handler;
this.options = options;
return 0;
}
}

describe('webLinks addon', () => {
describe('apply', () => {
it('should do register the `webLinksInit` method', () => {
webLinks.apply(<any>MockTerminal);
assert.equal(typeof (<any>MockTerminal).prototype.webLinksInit, 'function');
});
});

it('should allow ~ character in URI path', () => {
const term = new MockTerminal();
webLinks.webLinksInit(<any>term);

const row = ' http://foo.com/a~b#c~d?e~f ';

let match = row.match(term.regex);
let uri = match[term.options.matchIndex];

assert.equal(uri, 'http://foo.com/a~b#c~d?e~f');
});
});
48 changes: 48 additions & 0 deletions src/addons/webLinks/webLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/

/// <reference path="../../../typings/xterm.d.ts"/>

import { Terminal, ILinkMatcherOptions } from 'xterm';

const protocolClause = '(https?:\\/\\/)';
const domainCharacterSet = '[\\da-z\\.-]+';
const negatedDomainCharacterSet = '[^\\da-z\\.-]+';
const domainBodyClause = '(' + domainCharacterSet + ')';
const tldClause = '([a-z\\.]{2,6})';
const ipClause = '((\\d{1,3}\\.){3}\\d{1,3})';
const localHostClause = '(localhost)';
const portClause = '(:\\d{1,5})';
const hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + '|' + localHostClause + ')' + portClause + '?';
const pathClause = '(\\/[\\/\\w\\.\\-%~]*)*';
const queryStringHashFragmentCharacterSet = '[0-9\\w\\[\\]\\(\\)\\/\\?\\!#@$%&\'*+,:;~\\=\\.\\-]*';
const queryStringClause = '(\\?' + queryStringHashFragmentCharacterSet + ')?';
const hashFragmentClause = '(#' + queryStringHashFragmentCharacterSet + ')?';
const negatedPathCharacterSet = '[^\\/\\w\\.\\-%]+';
const bodyClause = hostClause + pathClause + queryStringClause + hashFragmentClause;
const start = '(?:^|' + negatedDomainCharacterSet + ')(';
const end = ')($|' + negatedPathCharacterSet + ')';
const strictUrlRegex = new RegExp(start + protocolClause + bodyClause + end);

function handleLink(event: MouseEvent, uri: string): void {
window.open(uri, '_blank');
}

/**
* Initialize the web links addon, registering the link matcher.
* @param term The terminal to use web links within.
* @param handler A custom handler to use.
* @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 {
options.matchIndex = 1;
term.registerLinkMatcher(strictUrlRegex, handler, options);
}

export function apply(terminalConstructor: typeof Terminal): void {
(<any>terminalConstructor.prototype).webLinksInit = function (handler?: (event: MouseEvent, uri: string) => void, options?: ILinkMatcherOptions): void {
webLinksInit(this, handler, options);
};
}
2 changes: 1 addition & 1 deletion src/addons/winptyCompat/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "xterm.winptyCompat",
"name": "xterm.winptycompat",
"main": "winptyCompat.js",
"private": true
}
2 changes: 1 addition & 1 deletion typings/xterm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ declare module 'xterm' {
* @param options Options for the link matcher.
* @return The ID of the new matcher, this can be used to deregister.
*/
registerLinkMatcher(regex: RegExp, handler: (event: MouseEvent, uri: string) => boolean | void, options?: ILinkMatcherOptions): number;
registerLinkMatcher(regex: RegExp, handler: (event: MouseEvent, uri: string) => void, options?: ILinkMatcherOptions): number;

/**
* (EXPERIMENTAL) Deregisters a link matcher if it has been registered.
Expand Down