hyper/app/hyperterm.js
Guillermo Rauch d8e841a3d8 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`
2016-07-03 13:35:45 -07:00

409 lines
12 KiB
JavaScript

import Tabs from './tabs';
import Term from './term';
import RPC from './rpc';
import Mousetrap from 'mousetrap';
import classes from 'classnames';
import shallowCompare from 'react-addons-shallow-compare';
import React, { Component } from 'react';
import UpdateChecker from './update-checker';
export default class HyperTerm extends Component {
constructor () {
super();
this.state = {
cols: null,
rows: null,
hpadding: 10,
vpadding: 5,
sessions: [],
titles: {},
urls: {},
active: null,
activeMarkers: [],
mac: /Mac/.test(navigator.userAgent),
resizeIndicatorShowing: false,
updateVersion: null
};
// we set this to true when the first tab
// has been initialized and ack'd by the
// node server for the *first time*
this.init = false;
// we keep track of activity in tabs to avoid
// placing an activity marker right after
// opening
this.tabWasActive = {};
this.onResize = this.onResize.bind(this);
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);
this.moveRight = this.moveRight.bind(this);
}
render () {
return <div onClick={ this.focusActive }>
<div className={ classes('main', { mac: this.state.mac }) }>
<header onMouseDown={this.onHeaderMouseDown}>
<Tabs
active={this.state.active}
activeMarkers={this.state.activeMarkers}
data={this.state.sessions.map((uid) => {
const title = this.state.titles[uid];
return null != title ? title : 'Shell';
})}
onChange={this.onChange}
/>
</header>
<div
className='terms'
style={{ padding: `${this.state.vpadding}px ${this.state.hpadding}px` }}
ref='termWrapper'>{
this.state.sessions.map((uid, i) => {
const active = i === this.state.active;
return <div key={`d${uid}`} className={classes('term', { active })} style={{ width: '100%', height: '100%' }} ref='term'>
<Term
key={uid}
ref={`term-${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>;
})
}</div>
</div>
<div className={classes('resize-indicator', { showing: this.state.resizeIndicatorShowing })}>
{ this.state.cols }x{ this.state.rows }
</div>
<div className={classes('update-indicator', { showing: null !== this.state.updateVersion })}>
Update available (<b>{ this.state.updateVersion }</b>).
{' '}
<a href='https://hyperterm.now.sh' onClick={this.openExternal} target='_blank'>Download</a>
</div>
</div>;
}
openExternal (ev) {
ev.preventDefault();
this.rpc.emit('open external', { url: ev.target.href });
}
requestTab () {
// 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);
}
}
closeBrowser () {
const uid = this.state.sessions[this.state.active];
if (this.state.urls[uid]) {
const urls = Object.assign({}, this.state.urls);
delete urls[uid];
this.setState({ urls });
}
}
write (uid, data) {
this.rpc.emit('data', { uid, data });
}
onURL (uid, url) {
const urls = Object.assign({}, this.state.urls, { [uid]: url });
this.setState({ urls });
}
onRemoteTitle ({ uid, title }) {
this.setTitle(uid, title);
}
setTitle (uid, title) {
const { titles: _titles } = this.state;
const titles = Object.assign({}, _titles, { [uid]: title });
this.setState({ titles });
}
onActive (uid) {
const i = this.state.sessions.indexOf(uid);
// we ignore activity markers all the way
// up to the tab's been active
const wasActive = this.tabWasActive[uid];
if (!wasActive) {
console.log('ignoring active, tab has not been focused', uid);
this.tabWasActive[uid] = true;
return;
}
if (this.state.active !== i && !~this.state.activeMarkers.indexOf(i)) {
const activeMarkers = this.state.activeMarkers.slice();
activeMarkers.push(i);
this.setState({ activeMarkers });
}
}
shouldComponentUpdate (nextProps, nextState) {
if (this.state.active !== nextState.active) {
const curUid = this.state.sessions[this.state.active];
// 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);
}
componentDidMount () {
this.rpc = new RPC();
this.updateChecker = new UpdateChecker(this.onUpdateAvailable.bind(this));
// open a new tab upon mounting
this.rpc.once('ready', () => this.requestTab());
this.rpc.on('new session', ({ uid }) => {
const { sessions: _sessions } = this.state;
const sessions = _sessions.concat(uid);
const state = { sessions };
state.active = sessions.length - 1;
this.setState(state, () => {
if (this.state.sessions.length && !this.init) {
this.rpc.emit('init');
this.init = true;
}
});
});
this.rpc.on('clear', this.clearCurrentTerm.bind(this));
this.rpc.on('exit', this.onSessionExit.bind(this));
this.rpc.on('data', ({ uid, data }) => {
if (this.ignoreActivity) {
// we ignore activity for up to 300ms after triggering
// a resize to avoid setting up markers incorrectly
if (Date.now() - this.ignoreActivity < 300) {
console.log('ignore activity after resizing');
} else {
this.ignoreActivity = null;
this.onActive(uid);
}
} else {
this.onActive(uid);
}
this.refs[`term-${uid}`].write(data);
});
this.rpc.on('new tab', this.requestTab.bind(this));
this.rpc.on('close tab', this.closeTab.bind(this));
this.rpc.on('title', this.onRemoteTitle.bind(this));
this.rpc.on('move left', this.moveLeft);
this.rpc.on('move right', this.moveRight);
}
clearCurrentTerm () {
const uid = this.state.sessions[this.state.active];
const term = this.refs[`term-${uid}`];
term.clear();
}
onUpdateAvailable (updateVersion) {
this.setState({ updateVersion });
}
moveTo (n) {
if (this.state.sessions[n]) {
this.setActive(n);
}
}
moveLeft () {
const next = this.state.active - 1;
if (this.state.sessions[next]) {
this.setActive(next);
} else if (this.state.sessions.length > 1) {
// go to the end
this.setActive(this.state.sessions.length - 1);
}
}
moveRight () {
const next = this.state.active + 1;
if (this.state.sessions[next]) {
this.setActive(next);
} else if (this.state.sessions.length > 1) {
// go to the beginning
this.setActive(0);
}
}
onSessionExit ({ uid }) {
if (!~this.state.sessions.indexOf(uid)) {
console.log('ignore exit of', uid);
return;
}
const {
sessions: _sessions,
titles: _titles,
active: _active,
activeMarkers
} = this.state;
const titles = Object.assign({}, _titles);
delete titles[uid];
delete this.tabWasActive[uid];
const i = _sessions.indexOf(uid);
const sessions = _sessions.slice();
sessions.splice(i, 1);
if (!sessions.length) {
return window.close();
}
const ai = activeMarkers.indexOf(i);
if (~ai) {
activeMarkers.splice(ai, 1);
}
let active;
if (i === _active) {
if (sessions.length) {
active = sessions[i - 1] ? i - 1 : i;
} else {
active = null;
}
} else if (i < _active) {
active = _active - 1;
}
if (~activeMarkers.indexOf(active)) {
activeMarkers.splice(active, 1);
}
this.setState({
sessions,
titles,
active,
activeMarkers
});
}
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();
}
focusActive () {
// get active uid and term
const uid = this.state.sessions[this.state.active];
const term = this.refs[`term-${uid}`];
if (term) {
term.focus();
}
}
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,
// 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(() => {
this.setState({ resizeIndicatorShowing: false });
}, 1500);
}
}
onChange (active) {
// we ignore clicks if they're a byproduct of a drag
// motion to move the window
if (window.screenX !== this.headerMouseDownWindowX ||
window.screenY !== this.headerMouseDownWindowY) {
return;
}
this.setActive(active);
}
setActive (active) {
if (~this.state.activeMarkers.indexOf(active)) {
const { activeMarkers } = this.state;
activeMarkers.splice(activeMarkers.indexOf(active), 1);
this.setState({ active, activeMarkers });
} else {
this.setState({ active });
}
}
onHeaderMouseDown () {
this.headerMouseDownWindowX = window.screenX;
this.headerMouseDownWindowY = window.screenY;
}
componentWillUnmount () {
this.rpc.destroy();
clearTimeout(this.resizeIndicatorTimeout);
if (this.keys) {
this.keys.reset();
}
this.updateChecker.destroy();
}
}