mirror of
https://github.com/quine-global/hyper.git
synced 2026-01-13 04:28:41 -09:00
517 lines
15 KiB
JavaScript
517 lines
15 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';
|
|
|
|
export default class HyperTerm extends Component {
|
|
constructor (props) {
|
|
super();
|
|
this.state = {
|
|
cols: null,
|
|
rows: null,
|
|
hpadding: 10,
|
|
vpadding: 5,
|
|
sessions: [],
|
|
titles: {},
|
|
urls: {},
|
|
active: null,
|
|
activeMarkers: [],
|
|
mac: /Mac/.test(navigator.userAgent),
|
|
resizeIndicatorShowing: false,
|
|
fontSizeIndicatorShowing: false,
|
|
dismissedUpdate: false,
|
|
updateVersion: null,
|
|
fontSize: props.config.fontSize
|
|
};
|
|
|
|
// 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.quitAndInstall = this.quitAndInstall.bind(this);
|
|
this.focusActive = this.focusActive.bind(this);
|
|
this.closeBrowser = this.closeBrowser.bind(this);
|
|
this.onHeaderMouseDown = this.onHeaderMouseDown.bind(this);
|
|
this.closeTab = this.closeTab.bind(this);
|
|
|
|
this.moveLeft = this.moveLeft.bind(this);
|
|
this.moveRight = this.moveRight.bind(this);
|
|
this.resetFontSize = this.resetFontSize.bind(this);
|
|
this.increaseFontSize = this.increaseFontSize.bind(this);
|
|
this.decreaseFontSize = this.decreaseFontSize.bind(this);
|
|
|
|
this.dismissUpdate = this.dismissUpdate.bind(this);
|
|
this.onUpdateAvailable = this.onUpdateAvailable.bind(this);
|
|
}
|
|
|
|
componentWillReceiveProps (props) {
|
|
if (props.config.fontSize !== this.props.config.fontSize) {
|
|
this.changeFontSize(props.config.fontSize);
|
|
}
|
|
}
|
|
|
|
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}
|
|
onClose={this.closeTab}
|
|
/>
|
|
</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;
|
|
const { config } = this.props;
|
|
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}
|
|
fontSize={this.state.fontSize}
|
|
cursorColor={config.cursorColor}
|
|
fontFamily={config.fontFamily}
|
|
backgroundColor={config.backgroundColor}
|
|
colors={config.colors}
|
|
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.fontSizeIndicatorShowing && <div>{ this.state.fontSize }px</div>}
|
|
<div>{ this.state.cols }x{ this.state.rows }</div>
|
|
</div>
|
|
<div className={classes('update-indicator', { showing: null !== this.state.updateVersion && !this.state.dismissedUpdate })}>
|
|
Version <b>{ this.state.updateVersion }</b> ready.
|
|
{this.state.updateNote ? ` ${this.state.updateNote}. ` : ' '}
|
|
<a href='' onClick={this.quitAndInstall}>Restart</a>
|
|
to apply <span className='close' onClick={this.dismissUpdate}>[x]</span>
|
|
</div>
|
|
</div>;
|
|
}
|
|
|
|
dismissUpdate () {
|
|
this.setState({ dismissedUpdate: true });
|
|
}
|
|
|
|
quitAndInstall (ev) {
|
|
ev.preventDefault();
|
|
this.rpc.emit('quit-and-install');
|
|
}
|
|
|
|
closeUpdateIndicator () {
|
|
// @TODO
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
closeActiveTab () {
|
|
this.closeTab(this.state.active);
|
|
}
|
|
|
|
closeTab (id) {
|
|
if (this.state.sessions.length) {
|
|
const uid = this.state.sessions[id];
|
|
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();
|
|
|
|
// 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.closeActiveTab.bind(this));
|
|
this.rpc.on('title', this.onRemoteTitle.bind(this));
|
|
|
|
this.rpc.on('move left', this.moveLeft);
|
|
this.rpc.on('move right', this.moveRight);
|
|
this.rpc.on('increase font size', this.increaseFontSize);
|
|
this.rpc.on('decrease font size', this.decreaseFontSize);
|
|
this.rpc.on('reset font size', this.resetFontSize);
|
|
|
|
this.rpc.on('update available', this.onUpdateAvailable);
|
|
this.rpc.on('preferences', this.editPreferences.bind(this));
|
|
}
|
|
|
|
clearCurrentTerm () {
|
|
const uid = this.state.sessions[this.state.active];
|
|
const term = this.refs[`term-${uid}`];
|
|
term.clear();
|
|
}
|
|
|
|
onUpdateAvailable ({ releaseName, releaseNotes = '' }) {
|
|
this.setState({
|
|
updateVersion: releaseName,
|
|
updateNote: releaseNotes.split(/\n/)[0].trim()
|
|
});
|
|
}
|
|
|
|
editPreferences () {
|
|
this.requestTab();
|
|
const onsession = ({ uid: _uid }) => {
|
|
const ondata = ({ uid }) => {
|
|
if (uid !== _uid) return;
|
|
this.rpc.removeListener('data', ondata);
|
|
this.rpc.emit('data', { uid, data: '# Attempting to open ~/.hyperterm.js with your $EDITOR\n' +
|
|
'# If this doesn\'t work, open it manually with your favorite editor!\n' +
|
|
'$EDITOR ~/.hyperterm.js\n' });
|
|
};
|
|
this.rpc.on('data', ondata);
|
|
};
|
|
this.rpc.once('new session', onsession);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
changeFontSize (value, { relative = false } = {}) {
|
|
this.setState({
|
|
fontSize: relative ? this.state.fontSize + value : value,
|
|
fontSizeIndicatorShowing: true
|
|
});
|
|
|
|
clearTimeout(this.fontSizeIndicatorTimeout);
|
|
this.fontSizeIndicatorTimeout = setTimeout(() => {
|
|
this.setState({ fontSizeIndicatorShowing: false });
|
|
}, 1500);
|
|
}
|
|
|
|
resetFontSize () {
|
|
this.changeFontSize(this.props.config.fontSize);
|
|
}
|
|
|
|
increaseFontSize () {
|
|
this.changeFontSize(1, { relative: true });
|
|
}
|
|
|
|
decreaseFontSize () {
|
|
this.changeFontSize(-1, { relative: true });
|
|
}
|
|
|
|
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;
|
|
|
|
this.clicks = this.clicks || 1;
|
|
|
|
if (this.clicks++ >= 2) {
|
|
if (this.maximized) {
|
|
this.rpc.emit('unmaximize');
|
|
} else {
|
|
this.rpc.emit('maximize');
|
|
}
|
|
this.clicks = 0;
|
|
this.maximized = !this.maximized;
|
|
} else {
|
|
// http://www.quirksmode.org/dom/events/click.html
|
|
// https://en.wikipedia.org/wiki/Double-click
|
|
this.clickTimer = setTimeout(() => {
|
|
this.clicks = 0;
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
componentWillUnmount () {
|
|
this.rpc.destroy();
|
|
clearTimeout(this.resizeIndicatorTimeout);
|
|
clearTimeout(this.fontSizeIndicatorTimeout);
|
|
if (this.keys) this.keys.reset();
|
|
delete this.clicks;
|
|
clearTimeout(this.clickTimer);
|
|
this.updateChecker.destroy();
|
|
}
|
|
}
|