Implement hterm (#28)

* remove legacy css

* hyperterm: delegate rows / cols calculation to hterm

* session: handle pty kill problems

* index: fix memory leak by removing sessions from the map upon exit

* app: remove local copy of `xterm.js`

* term: implement the `hterm` API and some needed overrides

* package: add `hterm-umd`

* hyperterm: add optimistic tab exit

* hyperterm: delegate key combination detection to the hterm <iframe> document

* term: register keyboard

* session: fix incorrect width after resizing and creating a new tab (#13)

* tabs: fix `user-select` css property

* term: fix focus issue when exiting a url

Instead of uninstalling the keyboard, we keep the
focus on the underlying terminal.

We register a new IO handler so that we intercept
all data events.

The reason we need to do this is that we can't
programmatically restore focus on the underlying
terminal unless it's in the same tick as a user
event (ie: click).

Since we were uninstalling the keyboard and
subsequently attempting to reinstall it without
such an event, pressing Ctrl+C after a url was
effectively resulting in a loss of focus and a
horrible horrible experience.

Now it's fixed :)

* text-metrics: remove module no longer used

hterm has a much better calculation technique anyways

* term: fix default bg

* term: fix nasty hterm bug that triggered an infinite copy loop

* index: add separator in `View` menu for full screen item

* term: implement cmd+K clearing and improve hterm's `wipeContents`
This commit is contained in:
Guillermo Rauch 2016-07-03 13:35:45 -07:00 committed by GitHub
parent c0394e7764
commit d8e841a3d8
9 changed files with 230 additions and 4938 deletions

View file

@ -35,11 +35,6 @@ header {
color: #fff;
}
.terms div {
font-family: Menlo;
font-size: 11px;
}
.term {
display: none;
}

View file

@ -9,7 +9,7 @@ nav {
vertical-align: middle;
color: #9B9B9B;
cursor: default;
user-select: none;
-webkit-user-select: none;
}
.single {

View file

@ -3,7 +3,6 @@ import Term from './term';
import RPC from './rpc';
import Mousetrap from 'mousetrap';
import classes from 'classnames';
import getTextMetrics from './text-metrics';
import shallowCompare from 'react-addons-shallow-compare';
import React, { Component } from 'react';
import UpdateChecker from './update-checker';
@ -12,6 +11,8 @@ export default class HyperTerm extends Component {
constructor () {
super();
this.state = {
cols: null,
rows: null,
hpadding: 10,
vpadding: 5,
sessions: [],
@ -38,6 +39,7 @@ export default class HyperTerm extends Component {
this.onChange = this.onChange.bind(this);
this.openExternal = this.openExternal.bind(this);
this.focusActive = this.focusActive.bind(this);
this.closeBrowser = this.closeBrowser.bind(this);
this.onHeaderMouseDown = this.onHeaderMouseDown.bind(this);
this.moveLeft = this.moveLeft.bind(this);
@ -65,16 +67,18 @@ export default class HyperTerm extends Component {
ref='termWrapper'>{
this.state.sessions.map((uid, i) => {
const active = i === this.state.active;
return <div key={`d${uid}`} className={classes('term', { active })} ref='term'>
return <div key={`d${uid}`} className={classes('term', { active })} style={{ width: '100%', height: '100%' }} ref='term'>
<Term
key={uid}
ref={`term-${uid}`}
url={this.state.urls[uid]}
cols={this.state.cols}
rows={this.state.rows}
url={this.state.urls[uid]}
onResize={this.onResize}
onTitle={this.setTitle.bind(this, uid)}
onData={this.write.bind(this, uid)}
onURL={this.onURL.bind(this, uid)}
onURLAbort={this.closeBrowser}
/>
</div>;
})
@ -97,13 +101,15 @@ export default class HyperTerm extends Component {
}
requestTab () {
this.rpc.emit('new', this.getDimensions());
// we send the hterm default size
this.rpc.emit('new', { cols: this.state.cols, rows: this.state.rows });
}
closeTab () {
if (this.state.sessions.length) {
const uid = this.state.sessions[this.state.active];
this.rpc.emit('exit', { uid });
this.onSessionExit(uid);
}
}
@ -157,11 +163,16 @@ export default class HyperTerm extends Component {
shouldComponentUpdate (nextProps, nextState) {
if (this.state.active !== nextState.active) {
const curUid = this.state.sessions[this.state.active];
if (curUid) {
// make sure that the blurred uid has not been
// optimistically removed
if (curUid && ~nextState.sessions.indexOf(curUid)) {
this.rpc.emit('blur', { uid: curUid });
}
const nextUid = nextState.sessions[nextState.active];
this.rpc.emit('focus', { uid: nextUid });
this.shouldInitKeys = true;
} else {
this.shouldInitKeys = false;
}
return shallowCompare(this, nextProps, nextState);
@ -170,7 +181,6 @@ export default class HyperTerm extends Component {
componentDidMount () {
this.rpc = new RPC();
this.updateChecker = new UpdateChecker(this.onUpdateAvailable.bind(this));
this.setState(this.getDimensions());
// open a new tab upon mounting
this.rpc.once('ready', () => this.requestTab());
@ -188,6 +198,7 @@ export default class HyperTerm extends Component {
});
});
this.rpc.on('clear', this.clearCurrentTerm.bind(this));
this.rpc.on('exit', this.onSessionExit.bind(this));
this.rpc.on('data', ({ uid, data }) => {
@ -210,31 +221,14 @@ export default class HyperTerm extends Component {
this.rpc.on('close tab', this.closeTab.bind(this));
this.rpc.on('title', this.onRemoteTitle.bind(this));
window.addEventListener('resize', this.onResize);
this.rpc.on('move left', this.moveLeft);
this.rpc.on('move right', this.moveRight);
}
Mousetrap.bind('command+1', this.moveTo.bind(this, 0));
Mousetrap.bind('command+2', this.moveTo.bind(this, 1));
Mousetrap.bind('command+3', this.moveTo.bind(this, 2));
Mousetrap.bind('command+4', this.moveTo.bind(this, 3));
Mousetrap.bind('command+5', this.moveTo.bind(this, 4));
Mousetrap.bind('command+6', this.moveTo.bind(this, 5));
Mousetrap.bind('command+7', this.moveTo.bind(this, 6));
Mousetrap.bind('command+8', this.moveTo.bind(this, 7));
Mousetrap.bind('command+9', this.moveTo.bind(this, 8));
Mousetrap.bind('ctrl+c', this.closeBrowser.bind(this));
Mousetrap.bind('command+shift+left', this.moveLeft);
Mousetrap.bind('command+shift+right', this.moveRight);
Mousetrap.bind('command+shift+[', this.moveLeft);
Mousetrap.bind('command+shift+]', this.moveRight);
Mousetrap.bind('command+alt+left', this.moveLeft);
Mousetrap.bind('command+alt+right', this.moveRight);
clearCurrentTerm () {
const uid = this.state.sessions[this.state.active];
const term = this.refs[`term-${uid}`];
term.clear();
}
onUpdateAvailable (updateVersion) {
@ -268,6 +262,11 @@ export default class HyperTerm extends Component {
}
onSessionExit ({ uid }) {
if (!~this.state.sessions.indexOf(uid)) {
console.log('ignore exit of', uid);
return;
}
const {
sessions: _sessions,
titles: _titles,
@ -317,6 +316,33 @@ export default class HyperTerm extends Component {
}
componentDidUpdate () {
if (this.shouldInitKeys) {
if (this.keys) {
this.keys.reset();
}
const uid = this.state.sessions[this.state.active];
const term = this.refs[`term-${uid}`];
const keys = new Mousetrap(term.getTermDocument());
keys.bind('command+1', this.moveTo.bind(this, 0));
keys.bind('command+2', this.moveTo.bind(this, 1));
keys.bind('command+3', this.moveTo.bind(this, 2));
keys.bind('command+4', this.moveTo.bind(this, 3));
keys.bind('command+5', this.moveTo.bind(this, 4));
keys.bind('command+6', this.moveTo.bind(this, 5));
keys.bind('command+7', this.moveTo.bind(this, 6));
keys.bind('command+8', this.moveTo.bind(this, 7));
keys.bind('command+9', this.moveTo.bind(this, 8));
keys.bind('command+shift+left', this.moveLeft);
keys.bind('command+shift+right', this.moveRight);
keys.bind('command+shift+[', this.moveLeft);
keys.bind('command+shift+]', this.moveRight);
keys.bind('command+alt+left', this.moveLeft);
keys.bind('command+alt+right', this.moveRight);
this.keys = keys;
}
this.focusActive();
}
@ -329,13 +355,15 @@ export default class HyperTerm extends Component {
}
}
onResize () {
const dim = this.getDimensions();
onResize (dim) {
if (dim.rows !== this.state.rows || dim.cols !== this.state.cols) {
this.ignoreActivity = Date.now();
this.rpc.emit('resize', dim);
const state = Object.assign({}, dim, { resizeIndicatorShowing: true });
const state = Object.assign({}, dim,
// if it's the first time we hear about the resize we
// don't show the indicator
null === this.state.rows ? {} : { resizeIndicatorShowing: true }
);
this.setState(state);
clearTimeout(this.resizeIndicatorTimeout);
this.resizeIndicatorTimeout = setTimeout(() => {
@ -370,24 +398,12 @@ export default class HyperTerm extends Component {
this.headerMouseDownWindowY = window.screenY;
}
getDimensions () {
const tm = getTextMetrics('Menlo', '11px', '15px');
const hp = this.state.hpadding;
const vp = this.state.vpadding;
const el = this.refs.termWrapper;
const { width, height } = el.getBoundingClientRect();
const dim = {
cols: Math.floor((width - hp * 2) / tm.width),
rows: Math.floor((height - vp * 2) / tm.height)
};
return dim;
}
componentWillUnmount () {
window.removeEventListener('resize', this.onResize);
this.rpc.destroy();
clearTimeout(this.resizeIndicatorTimeout);
Mousetrap.reset();
if (this.keys) {
this.keys.reset();
}
this.updateChecker.destroy();
}
}

View file

@ -3,13 +3,14 @@
"version": "0.0.1",
"description": "",
"dependencies": {
"react-addons-shallow-compare": "15.1.0",
"mousetrap": "1.6.0",
"classnames": "2.2.5",
"hterm-umd": "1.0.1",
"json-loader": "0.5.4",
"mousetrap": "1.6.0",
"react": "15.1.0",
"react-addons-shallow-compare": "15.1.0",
"react-dom": "15.1.0",
"semver-compare": "^1.0.0",
"json-loader": "^0.5.4"
"semver-compare": "1.0.0"
},
"devDependencies": {
"eslint": "2.13.1",

View file

@ -1,38 +1,107 @@
import Terminal from './xterm';
/*global URL:false,Blob:false*/
import React, { Component } from 'react';
import { hterm, lib as htermLib } from 'hterm-umd';
hterm.defaultStorage = new htermLib.Storage.Memory();
// override double click behavior to copy
const oldMouse = hterm.Terminal.prototype.onMouse_;
hterm.Terminal.prototype.onMouse_ = function (e) {
if ('dblclick' === e.type) {
console.log('[hyperterm+hterm] ignore double click');
return;
}
return oldMouse.call(this, e);
};
// there's no option to turn off the size overlay
hterm.Terminal.prototype.overlaySize = function () {};
// fixing a bug in hterm where a double click triggers
// a non-collapsed selection whose text is '', and results
// in an infinite copy loop
hterm.Terminal.prototype.copySelectionToClipboard = function () {
var text = this.getSelectionText();
if (text != null && text !== '') {
this.copyStringToClipboard(text);
}
};
// passthrough all the commands that are meant to control
// hyperterm and not the terminal itself
const oldKeyDown = hterm.Keyboard.prototype.onKeyDown_;
hterm.Keyboard.prototype.onKeyDown_ = function (e) {
if (e.metaKey) {
return;
}
return oldKeyDown.call(this, e);
};
const oldKeyPress = hterm.Keyboard.prototype.onKeyPress_;
hterm.Keyboard.prototype.onKeyPress_ = function (e) {
if (e.metaKey) {
return;
}
return oldKeyPress.call(this, e);
};
const domainRegex = /\b((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63}\b/;
export default class Term extends Component {
componentDidMount () {
this.term = new Terminal({
cols: this.props.cols,
rows: this.props.rows
});
this.term.on('data', (data) => {
this.props.onData(data);
});
this.term.on('title', (title) => {
this.props.onTitle(title);
});
this.term.open(this.refs.term);
this.term = new hterm.Terminal();
// the first term that's created has unknown size
// subsequent new tabs have size
if (this.props.cols) {
this.term.realizeSize_(this.props.cols, this.props.rows);
}
this.term.prefs_.set('font-family', 'Menlo');
this.term.prefs_.set('font-size', 11);
this.term.prefs_.set('cursor-color', '#F81CE5');
this.term.prefs_.set('enable-clipboard-notice', false);
this.term.prefs_.set('background-color', '#000');
this.term.prefs_.set('user-css', URL.createObjectURL(new Blob([`
.cursor-node[focus="false"] {
border-width: 1px !important;
}
`]), { type: 'text/css' }));
this.term.onTerminalReady = () => {
const io = this.term.io.push();
io.onVTKeystroke = io.sendString = (str) => {
this.props.onData(str);
};
io.onTerminalResize = (cols, rows) => {
this.props.onResize({ cols, rows });
};
};
this.term.decorate(this.refs.term);
this.term.installKeyboard();
}
getTermDocument () {
return this.term.document_;
}
shouldComponentUpdate (nextProps) {
if (nextProps.rows !== this.props.rows || nextProps.cols !== this.props.cols) {
this.term.resize(nextProps.cols, nextProps.rows);
}
if (this.props.url !== nextProps.url) {
// when the url prop changes, we make sure
// the terminal starts or stops ignoring
// key input so that it doesn't conflict
// with the <webview>
if (nextProps.url) {
this.term.ignoreKeyEvents = true;
const io = this.term.io.push();
io.onVTKeystroke = io.sendString = (str) => {
if (1 === str.length && 3 === str.charCodeAt(0) /* Ctrl + C */) {
this.props.onURLAbort();
}
};
} else {
this.term.ignoreKeyEvents = false;
this.term.io.pop();
}
return true;
}
@ -51,20 +120,67 @@ export default class Term extends Component {
return;
}
}
this.term.write(data);
this.term.io.print(data);
}
focus () {
this.term.element.focus();
this.term.focus();
}
clear () {
const { term } = this;
// we re-implement `wipeContents` to preserve the line
// and cursor position that the client is in.
// otherwise the user ends up with a completely clear
// screen which is really strange
term.scrollbackRows_.length = 0;
term.scrollPort_.resetCache();
[term.primaryScreen_, term.alternateScreen_].forEach(function (screen) {
const bottom = screen.getHeight();
if (bottom > 0) {
term.renumberRows_(0, bottom);
const x = screen.cursorPosition.column;
const y = screen.cursorPosition.row;
if (y === 0) {
// Empty screen, nothing to do.
return;
}
for (let i = 0; i < y; i++) {
screen.setCursorPosition(i, 0);
screen.clearCursorRow();
}
// here we move the row that the user was focused on
// to the top of the screen
term.moveRows_(y, 1, 0);
// we restore the cursor position
screen.setCursorPosition(0, x);
}
});
term.syncCursorPosition_();
term.scrollPort_.invalidate();
// this will avoid a bug where the `wipeContents`
// hterm API doens't send the scroll to the top
this.term.scrollPort_.redraw_();
}
componentWillUnmount () {
this.term.destroy();
// there's no need to manually destroy
// as all the events are attached to the iframe
// which gets removed
}
render () {
return <div>
<div ref='term' />
return <div style={{ width: '100%', height: '100%' }}>
<div ref='term' style={{ position: 'relative', width: '100%', height: '100%' }} />
{ this.props.url
? <webview
src={this.props.url}

View file

@ -1,20 +0,0 @@
const mem = new Map();
export default function getTextMetrics (family, fontSize, lineHeight) {
const id = family + '#' + fontSize + '#' + lineHeight;
const memd = mem.get(id);
if (memd) return memd;
const el = document.createElement('span');
const style = el.style;
style.display = 'inline-block';
style.fontFamily = family;
style.fontSize = fontSize;
style.lineHeight = lineHeight;
el.innerText = 'X';
document.body.appendChild(el);
const { width, height } = el.getBoundingClientRect();
const ret = { width, height };
document.body.removeChild(el);
mem.set(id, ret);
console.log('text metrics calculated for', family, fontSize, lineHeight, ret);
return ret;
}

File diff suppressed because it is too large Load diff

View file

@ -68,6 +68,7 @@ app.on('ready', () => {
session.on('exit', () => {
rpc.emit('exit', { uid });
sessions.delete(uid);
});
});
});
@ -184,7 +185,17 @@ app.on('ready', () => {
submenu: [
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' },
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' },
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', selector: 'selectAll:' }
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', selector: 'selectAll:' },
{ type: 'separator' },
{
label: 'Clear',
accelerator: 'CmdOrCtrl+K',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('clear');
}
}
}
]
},
{
@ -206,6 +217,9 @@ app.on('ready', () => {
}
}
},
{
type: 'separator'
},
{
role: 'togglefullscreen'
}

View file

@ -7,10 +7,10 @@ const TITLE_POLL_INTERVAL = 1000;
module.exports = class Session extends EventEmitter {
constructor ({ rows, cols }) {
constructor ({ rows, cols: columns }) {
super();
this.pty = spawn(defaultShell, ['--login'], {
cols,
columns,
rows,
cwd: process.env.HOME,
env: Object.assign({}, process.env, {
@ -88,7 +88,11 @@ module.exports = class Session extends EventEmitter {
}
destroy () {
this.pty.kill('SIGHUP');
try {
this.pty.kill('SIGHUP');
} catch (err) {
console.error('exit error', err.stack);
}
this.emit('exit');
this.ended = true;
clearTimeout(this.titlePoll);