diff --git a/.gitignore b/.gitignore index af5c7e2e..029efd56 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,5 @@ Makefile.gyp *.target.gyp.mk *.node example/*.log -docs/_build +docs/ npm-debug.log diff --git a/addons/linkify/linkify.js b/addons/linkify/linkify.js index 152404d4..f02c2023 100644 --- a/addons/linkify/linkify.js +++ b/addons/linkify/linkify.js @@ -30,8 +30,10 @@ negatedDomainCharacterSet = '[^\\da-z\\.-]+', domainBodyClause = '(' + domainCharacterSet + ')', tldClause = '([a-z\\.]{2,6})', - hostClause = domainBodyClause + '\\.' + tldClause, - pathClause = '([\\/\\w\\.-]*)*\\/?', + ipClause = '((\\d{1,3}\\.){3}\\d{1,3})', + portClause = '(:\\d{1,5})', + hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + ')' + portClause + '?', + pathClause = '(\\/[\\/\\w\\.-]*)*', negatedPathCharacterSet = '[^\\/\\w\\.-]+', bodyClause = hostClause + pathClause, start = '(?:^|' + negatedDomainCharacterSet + ')(', @@ -41,7 +43,6 @@ lenientUrlRegex = new RegExp(lenientUrlClause), strictUrlRegex = new RegExp(strictUrlClause); - /** * Converts all valid URLs found in the given terminal line into * hyperlinks. The terminal line can be either the HTML element itself @@ -90,22 +91,13 @@ continue; } + var url = exports.findLinkMatch(node.data, lenient); - if (lenient) { - match = node.data.match(lenientUrlRegex); - } else { - match = node.data.match(strictUrlRegex); - } - - /** - * If no URL was found in the current text, return. - */ - if (!match) { + if (!url) { continue; } - var url = match[1], - startsWithProtocol = new RegExp('^' + protocolClause), + var startsWithProtocol = new RegExp('^' + protocolClause), urlHasProtocol = url.match(startsWithProtocol), href = (urlHasProtocol) ? url : 'http://' + url, link = '' + url + '', @@ -124,6 +116,20 @@ terminal.emit('linkify:line', line); }; + /** + * Finds a link within a block of text. + * + * @param {string} text - The text to search . + * @param {boolean} lenient - Whether to use the lenient search. + * @return {string} A URL. + */ + exports.findLinkMatch = function (text, lenient) { + var match = text.match(lenient ? lenientUrlRegex : strictUrlRegex); + if (!match || match.length === 0) { + return null; + } + return match[1]; + } /** * Converts all valid URLs found in the terminal view into hyperlinks. diff --git a/conf.json b/conf.json deleted file mode 100644 index b5c8e525..00000000 --- a/conf.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "source": { - "include": [ - "src/xterm.js", - "addons/attach/attach.js", - "addons/fit/fit.js", - "addons/fullscreen/fullscreen.js", - "addons/linkify/linkify.js" - ] - }, - "opts": { - "readme": "README.md" - } -} \ No newline at end of file diff --git a/demo/index.html b/demo/index.html index 92ccc94c..ff10a5cb 100644 --- a/demo/index.html +++ b/demo/index.html @@ -9,12 +9,16 @@ -

xterm.js: xterm, in the browser

+
+

Options

+ +
+ diff --git a/demo/main.js b/demo/main.js index 70d64c59..59bdacdd 100644 --- a/demo/main.js +++ b/demo/main.js @@ -1,11 +1,36 @@ -var terminalContainer = document.getElementById('terminal-container'), - term = new Terminal(), - protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://', - socketURL = protocol + location.hostname + ((location.port) ? (':' + location.port) : '') + '/bash', - socket = new WebSocket(socketURL); +var term, + protocol, + socketURL, + socket; + +var terminalContainer = document.getElementById('terminal-container'); +var optionElements = { + cursorBlink: document.querySelector('#option-cursor-blink') +}; + +optionElements.cursorBlink.addEventListener('change', createTerminal); + +createTerminal(); + +function createTerminal() { + while (terminalContainer.children.length) { + terminalContainer.removeChild(terminalContainer.children[0]); + } + term = new Terminal({ + cursorBlink: optionElements.cursorBlink.checked + }); + protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://'; + socketURL = protocol + location.hostname + ((location.port) ? (':' + location.port) : '') + '/bash'; + socket = new WebSocket(socketURL); + + term.open(terminalContainer); + term.fit(); + + socket.onopen = runRealTerminal; + socket.onclose = runFakeTerminal; + socket.onerror = runFakeTerminal; +} -term.open(terminalContainer); -term.fit(); function runRealTerminal() { term.attach(socket); @@ -54,7 +79,3 @@ function runFakeTerminal() { term.write(data); }); } - -socket.onopen = runRealTerminal; -socket.onclose = runFakeTerminal; -socket.onerror = runFakeTerminal; diff --git a/jsdoc.json b/jsdoc.json new file mode 100644 index 00000000..28ca16cb --- /dev/null +++ b/jsdoc.json @@ -0,0 +1,23 @@ +{ + "source": { + "include": [ + "src/xterm.js", + "addons/attach/attach.js", + "addons/fit/fit.js", + "addons/fullscreen/fullscreen.js", + "addons/linkify/linkify.js" + ] + }, + "opts": { + "readme": "README.md", + "template": "node_modules/docdash", + "encoding": "utf8", + "destination": "docs/", + "recurse": true, + "verbose": true + }, + "templates": { + "cleverLinks": false, + "monospaceLinks": false + } +} diff --git a/package.json b/package.json index 73fde3c7..1b98517c 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,13 @@ "express-ws": "2.0.0-rc.1", "pty.js": "0.3.0", "mocha": "2.5.3", - "chai": "3.5.0" + "chai": "3.5.0", + "jsdoc": "3.4.0", + "docdash": "0.4.0" }, "scripts": { "start": "bash bin/server", - "test": "bash bin/test" + "test": "bash bin/test --recursive", + "build:docs": "node_modules/.bin/jsdoc -c jsdoc.json" } } diff --git a/src/xterm.js b/src/xterm.js index 22106e5e..1ac384c8 100644 --- a/src/xterm.js +++ b/src/xterm.js @@ -142,6 +142,14 @@ * Terminal */ + /** + * Creates a new `Terminal` object. + * + * @param {object} options An object containing a set of options, the available options are: + * - cursorBlink (boolean): Whether the terminal cursor blinks + * + * @public + */ function Terminal(options) { var self = this; @@ -222,6 +230,16 @@ */ this.y = 0; + /** + * Used to debounce the refresh function + */ + this.isRefreshing = false; + + /** + * Whether there is a full terminal refresh queued + */ + this.queuedRefresh = false; + this.cursorState = 0; this.cursorHidden = false; this.convertEol; @@ -289,6 +307,7 @@ this.tabs; this.setupStops(); + this.debounceRefresh(); } inherits(Terminal, EventEmitter); @@ -299,6 +318,26 @@ return (this.defAttr & ~0x1ff) | (this.curAttr & 0x1ff); }; + /** + * Allow refresh to execute only approximately 30 times a second. For commands that pass a + * significant amount of output to the write function, this prevents the terminal from maxing + * out the CPU and making the UI unresponsive. While commands can still run beyond what they do + * on the terminal, it is far better with a debounce in place as every single terminal + * manipulation does not need to be constructed in the DOM. + * + * A side-effect of this is that it makes ^C to interrupt a process seem more responsive. + */ + Terminal.prototype.debounceRefresh = function () { + var self = this; + window.setInterval(function () { + self.isRefreshing = false; + if (self.queuedRefresh) { + // Do a full refresh in case multiple refreshes were requested. + self.refresh(0, self.rows - 1); + } + }, 34); + }; + /** * Colors */ @@ -408,7 +447,9 @@ }); /** - * Focused Terminal + * Focus the terminal. + * + * @public */ Terminal.prototype.focus = function() { if (document.activeElement === this.element) { @@ -636,8 +677,12 @@ }; - /* - * Open Terminal in the DOM + /** + * Opens the terminal within an element. + * + * @param {HTMLElement} parent The element to create the terminal within. + * + * @public */ Terminal.prototype.open = function(parent) { var self=this, i=0, div; @@ -745,6 +790,26 @@ this.emit('open'); }; + + /** + * Attempts to load an add-on using CommonJS or RequireJS (whichever is available). + * @param {string} addon The name of the addon to load + * @static + */ + Terminal.loadAddon = function(addon, callback) { + if (typeof exports === 'object' && typeof module === 'object') { + // CommonJS + return require(__dirname + '/../addons/' + addon); + } else if (typeof define == 'function') { + // RequireJS + return require(['../addons/' + addon + '/' + addon], callback); + } else { + console.error('Cannot load a module without a CommonJS or RequireJS environment.'); + return false; + } + }; + + // XTerm mouse events // http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking // To better understand these @@ -1084,9 +1149,10 @@ }; /** - * Destroy Terminal + * Destroys the terminal. + * + * @public */ - Terminal.prototype.destroy = function() { this.readable = false; this.writable = false; @@ -1124,9 +1190,24 @@ * Next 14 bits: a mask for misc. flags: * 1=bold, 2=underline, 4=blink, 8=inverse, 16=invisible */ + + /** + * Refreshes terminal content within two rows (inclusive). + * + * @param {number} start The row to start from (between 0 and terminal's height terminal - 1) + * @param {number} end The row to end at (between fromRow and terminal's height terminal - 1) + * + * @public + */ Terminal.prototype.refresh = function(start, end) { var x, y, i, line, out, ch, width, data, attr, bg, fg, flags, row, parent, focused = document.activeElement; + if (this.isRefreshing) { + this.queuedRefresh = true; + return; + } + this.isRefreshing = true; + if (end - start >= this.rows / 2) { parent = this.element.parentNode; if (parent) parent.removeChild(this.element); @@ -1135,9 +1216,9 @@ width = this.cols; y = start; - if (end >= this.lines.length) { + if (end >= this.rows.length) { this.log('`end` is too large. Most likely a bad CSR.'); - end = this.lines.length - 1; + end = this.rows.length - 1; } for (; y <= end; y++) { @@ -1360,6 +1441,13 @@ this.refresh(0, this.rows - 1); }; + /** + * Writes text to the terminal. + * + * @param {string} text The text to write to the terminal. + * + * @public + */ Terminal.prototype.write = function(data) { var l = data.length, i = 0, j, cs, ch; @@ -2649,6 +2737,14 @@ this.context.console.error.apply(this.context.console, args); }; + /** + * Resizes the terminal. + * + * @param {number} x The number of columns to resize to. + * @param {number} y The number of rows to resize to. + * + * @public + */ Terminal.prototype.resize = function(x, y) { var line , el @@ -4571,6 +4667,15 @@ Terminal.EventEmitter = EventEmitter; Terminal.inherits = inherits; + + /** + * Adds an event listener to the terminal. + * + * @param {string} event The name of the event. TODO: Document all event types + * @param {function} callback The function to call when the event is triggered. + * + * @public + */ Terminal.on = on; Terminal.off = off; Terminal.cancel = cancel; diff --git a/test/addons/linkify-test.js b/test/addons/linkify-test.js new file mode 100644 index 00000000..7cb23186 --- /dev/null +++ b/test/addons/linkify-test.js @@ -0,0 +1,48 @@ +var assert = require('chai').assert; +var Terminal = require('../../src/xterm'); +var linkify = require('../../addons/linkify/linkify'); + +describe('linkify addon', function () { + var xterm; + + describe('API', function () { + it('should define Terminal.prototype.linkify', function () { + assert.isDefined(Terminal.prototype.linkify); + }); + it('should define Terminal.prototype.linkifyTerminalLine', function () { + assert.isDefined(Terminal.prototype.linkifyTerminalLine); + }); + }); + + describe('findUrlMatchOnLine', function () { + describe('strict regex', function () { + it('should match when the entire text is a match', function () { + assert.equal(linkify.findLinkMatch('http://github.com', false), 'http://github.com'); + assert.equal(linkify.findLinkMatch('http://127.0.0.1', false), 'http://127.0.0.1'); + }); + it('should match simple domains', function () { + assert.equal(linkify.findLinkMatch('foo http://github.com bar', false), 'http://github.com'); + assert.equal(linkify.findLinkMatch('foo http://www.github.com bar', false), 'http://www.github.com'); + assert.equal(linkify.findLinkMatch('foo https://github.com bar', false), 'https://github.com'); + assert.equal(linkify.findLinkMatch('foo https://www.github.com bar', false), 'https://www.github.com'); + }); + it('should match web addresses with alpha paths', function () { + assert.equal(linkify.findLinkMatch('foo http://github.com/a/b/c bar', false), 'http://github.com/a/b/c'); + assert.equal(linkify.findLinkMatch('foo http://www.github.com/a/b/c bar', false), 'http://www.github.com/a/b/c'); + }); + it('should not include whitespace surrounding a match', function () { + assert.equal(linkify.findLinkMatch(' http://github.com', false), 'http://github.com'); + assert.equal(linkify.findLinkMatch('http://github.com ', false), 'http://github.com'); + assert.equal(linkify.findLinkMatch(' http://github.com ', false), 'http://github.com'); + }); + it('should match IP addresses', function () { + assert.equal(linkify.findLinkMatch('foo http://127.0.0.1 bar', false), 'http://127.0.0.1'); + assert.equal(linkify.findLinkMatch('foo https://127.0.0.1 bar', false), 'https://127.0.0.1'); + }); + it('should match ports on both domains and IP addresses', function () { + assert.equal(linkify.findLinkMatch('foo http://127.0.0.1:8080 bar', false), 'http://127.0.0.1:8080'); + assert.equal(linkify.findLinkMatch('foo http://www.github.com:8080 bar', false), 'http://www.github.com:8080'); + }); + }); + }); +}); diff --git a/test/addons/test.js b/test/addons/test.js new file mode 100644 index 00000000..ba689e6e --- /dev/null +++ b/test/addons/test.js @@ -0,0 +1,10 @@ +var assert = require('chai').assert; +var Terminal = require('../../src/xterm'); + +describe('xterm.js addons', function() { + it('should load addons with Terminal.loadAddon', function () { + Terminal.loadAddon('attach'); + // Test that function was loaded successfully + assert.equal(typeof Terminal.prototype.attach, 'function'); + }); +}); diff --git a/test/test.js b/test/test.js index 6f7a0791..fcfd8832 100644 --- a/test/test.js +++ b/test/test.js @@ -79,7 +79,7 @@ describe('xterm.js', function() { xterm.handler = function() {}; xterm.showCursor = function() {}; xterm.clearSelection = function() {}; - }) + }); describe('On Mac OS', function() { beforeEach(function() {