mirror of
https://github.com/quine-global/hyper.git
synced 2026-01-12 20:18:41 -09:00
initial commit
This commit is contained in:
commit
1a1178bd38
23 changed files with 9463 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
5
TODO.md
Normal file
5
TODO.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# TODO
|
||||||
|
|
||||||
|
- [ ] Listen on `resize` event *coming* from the pty.
|
||||||
|
- [ ] Add support for zsh.
|
||||||
|
- [ ] Figure out process title extraction on other platforms.
|
||||||
65
app/css/hyperterm.css
Normal file
65
app/css/hyperterm.css
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
.main {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mac.main {
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
position: fixed;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
right: 1px;
|
||||||
|
background: #000;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mac header {
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms {
|
||||||
|
position: absolute;
|
||||||
|
margin-top: 28px;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terms div {
|
||||||
|
font-family: Menlo;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.term {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.term.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-indicator {
|
||||||
|
background: rgba(255, 255, 255, .2);
|
||||||
|
padding: 6px 14px;
|
||||||
|
color: #fff;
|
||||||
|
font: 11px Menlo;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-indicator.showing {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
73
app/css/tabs.css
Normal file
73
app/css/tabs.css
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
nav {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont,
|
||||||
|
"Segoe UI", "Roboto", "Oxygen",
|
||||||
|
"Ubuntu", "Cantarell", "Fira Sans",
|
||||||
|
"Droid Sans", "Helvetica Neue", sans-serif;
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #9B9B9B;
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single {
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
max-height: 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs li:first-child {
|
||||||
|
margin-left: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs li {
|
||||||
|
list-style-type: none;
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs li.is_active {
|
||||||
|
color: #fff;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs li span {
|
||||||
|
display: block;
|
||||||
|
border-left: 1px solid transparent;
|
||||||
|
border-right: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs li.is_active span {
|
||||||
|
border-left-color: #333;
|
||||||
|
border-right-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs li.is_active:last-child span {
|
||||||
|
border-right-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs li.is_active::before {
|
||||||
|
position: absolute;
|
||||||
|
content: ' ';
|
||||||
|
border-bottom: 1px solid #000;
|
||||||
|
display: block;
|
||||||
|
left: 1px;
|
||||||
|
right: 1px;
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs li:not(.is_active):hover {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs li.has_activity, .tabs li.has_activity:hover {
|
||||||
|
color: #50E3C2;
|
||||||
|
}
|
||||||
2156
app/css/xterm.css
Normal file
2156
app/css/xterm.css
Normal file
File diff suppressed because it is too large
Load diff
1160
app/dist/bundle.js
vendored
Normal file
1160
app/dist/bundle.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
app/dist/bundle.js.map
vendored
Normal file
1
app/dist/bundle.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
363
app/hyperterm.js
Normal file
363
app/hyperterm.js
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
import Tabs from './tabs';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export default class HyperTerm extends Component {
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
this.state = {
|
||||||
|
hpadding: 10,
|
||||||
|
vpadding: 5,
|
||||||
|
sessions: [],
|
||||||
|
titles: {},
|
||||||
|
urls: {},
|
||||||
|
active: null,
|
||||||
|
activeMarkers: [],
|
||||||
|
mac: /Mac/.test(navigator.userAgent),
|
||||||
|
resizeIndicatorShowing: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.focusActive = this.focusActive.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 })} ref='term'>
|
||||||
|
<Term
|
||||||
|
key={uid}
|
||||||
|
ref={`term-${uid}`}
|
||||||
|
url={this.state.urls[uid]}
|
||||||
|
cols={this.state.cols}
|
||||||
|
rows={this.state.rows}
|
||||||
|
onTitle={this.setTitle.bind(this, uid)}
|
||||||
|
onData={this.write.bind(this, uid)}
|
||||||
|
onURL={this.onURL.bind(this, uid)}
|
||||||
|
/>
|
||||||
|
</div>;
|
||||||
|
})
|
||||||
|
}</div>
|
||||||
|
</div>
|
||||||
|
<div className={classes('resize-indicator', { showing: this.state.resizeIndicatorShowing })}>
|
||||||
|
{ this.state.cols }x{ this.state.rows }
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestTab () {
|
||||||
|
this.rpc.emit('new', this.getDimensions());
|
||||||
|
}
|
||||||
|
|
||||||
|
closeTab () {
|
||||||
|
if (this.state.sessions.length) {
|
||||||
|
const uid = this.state.sessions[this.state.active];
|
||||||
|
|
||||||
|
if (this.state.urls[uid]) {
|
||||||
|
// if we have a url loaded, closing a tab
|
||||||
|
// instead closes the url
|
||||||
|
const urls = Object.assign({}, this.state.urls);
|
||||||
|
delete urls[uid];
|
||||||
|
this.setState({ urls });
|
||||||
|
} else {
|
||||||
|
this.rpc.emit('exit', { uid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
if (curUid) {
|
||||||
|
this.rpc.emit('blur', { uid: curUid });
|
||||||
|
}
|
||||||
|
const nextUid = nextState.sessions[nextState.active];
|
||||||
|
this.rpc.emit('focus', { uid: nextUid });
|
||||||
|
}
|
||||||
|
|
||||||
|
return shallowCompare(this, nextProps, nextState);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this.rpc = new RPC();
|
||||||
|
this.setState(this.getDimensions());
|
||||||
|
|
||||||
|
// 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('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));
|
||||||
|
|
||||||
|
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('command+shift+left', this.moveLeft);
|
||||||
|
Mousetrap.bind('command+shift+right', this.moveRight);
|
||||||
|
|
||||||
|
Mousetrap.bind('command+alt+left', this.moveLeft);
|
||||||
|
Mousetrap.bind('command+alt+right', this.moveRight);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moveRight () {
|
||||||
|
const next = this.state.active + 1;
|
||||||
|
if (this.state.sessions[next]) {
|
||||||
|
this.setActive(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSessionExit ({ uid }) {
|
||||||
|
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 () {
|
||||||
|
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 () {
|
||||||
|
const dim = this.getDimensions();
|
||||||
|
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 });
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/index.html
Normal file
27
app/index.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>HyperTerm</title>
|
||||||
|
<meta name="viewport" content="initial-scale=1.0" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="mount"></div>
|
||||||
|
<script>start = performance.now();</script>
|
||||||
|
<script src="dist/bundle.js"></script>
|
||||||
|
<script>console.log(performance.now() - start);</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
app/index.js
Normal file
9
app/index.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { render } from 'react-dom';
|
||||||
|
import HyperTerm from './hyperterm';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
require('./css/hyperterm.css');
|
||||||
|
require('./css/tabs.css');
|
||||||
|
require('./css/xterm.css');
|
||||||
|
|
||||||
|
render(<HyperTerm />, document.getElementById('mount'));
|
||||||
45
app/npm-debug.log
Normal file
45
app/npm-debug.log
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
0 info it worked if it ends with ok
|
||||||
|
1 verbose cli [ '/usr/local/bin/node', '/usr/local/bin/npm', 'run', 'lint' ]
|
||||||
|
2 info using npm@3.9.3
|
||||||
|
3 info using node@v6.2.1
|
||||||
|
4 verbose run-script [ 'prelint', 'lint', 'postlint' ]
|
||||||
|
5 info lifecycle hyperterm-web@0.0.1~prelint: hyperterm-web@0.0.1
|
||||||
|
6 silly lifecycle hyperterm-web@0.0.1~prelint: no script for prelint, continuing
|
||||||
|
7 info lifecycle hyperterm-web@0.0.1~lint: hyperterm-web@0.0.1
|
||||||
|
8 verbose lifecycle hyperterm-web@0.0.1~lint: unsafe-perm in lifecycle true
|
||||||
|
9 verbose lifecycle hyperterm-web@0.0.1~lint: PATH: /usr/local/lib/node_modules/npm/bin/node-gyp-bin:/Users/rauchg/Projects/zeit/hyperterm/app/node_modules/.bin:/usr/local/bin:/Users/rauchg/google-cloud-sdk/bin:/Users/rauchg/.rvm/gems/ruby-2.2.1/bin:/Users/rauchg/.rvm/gems/ruby-2.2.1@global/bin:/Users/rauchg/.rvm/rubies/ruby-2.2.1/bin:/Users/rauchg/google-cloud-sdk/bin:./node_modules/.bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:./bin:/Users/rauchg/bin:/Users/rauchg/.rvm/bin:/Users/rauchg/go/bin:/usr/local/opt/go/libexec/bin
|
||||||
|
10 verbose lifecycle hyperterm-web@0.0.1~lint: CWD: /Users/rauchg/Projects/zeit/hyperterm/app
|
||||||
|
11 silly lifecycle hyperterm-web@0.0.1~lint: Args: [ '-c', 'eslint *.js' ]
|
||||||
|
12 silly lifecycle hyperterm-web@0.0.1~lint: Returned: code: 1 signal: null
|
||||||
|
13 info lifecycle hyperterm-web@0.0.1~lint: Failed to exec lint script
|
||||||
|
14 verbose stack Error: hyperterm-web@0.0.1 lint: `eslint *.js`
|
||||||
|
14 verbose stack Exit status 1
|
||||||
|
14 verbose stack at EventEmitter.<anonymous> (/usr/local/lib/node_modules/npm/lib/utils/lifecycle.js:245:16)
|
||||||
|
14 verbose stack at emitTwo (events.js:106:13)
|
||||||
|
14 verbose stack at EventEmitter.emit (events.js:191:7)
|
||||||
|
14 verbose stack at ChildProcess.<anonymous> (/usr/local/lib/node_modules/npm/lib/utils/spawn.js:24:14)
|
||||||
|
14 verbose stack at emitTwo (events.js:106:13)
|
||||||
|
14 verbose stack at ChildProcess.emit (events.js:191:7)
|
||||||
|
14 verbose stack at maybeClose (internal/child_process.js:852:16)
|
||||||
|
14 verbose stack at Process.ChildProcess._handle.onexit (internal/child_process.js:215:5)
|
||||||
|
15 verbose pkgid hyperterm-web@0.0.1
|
||||||
|
16 verbose cwd /Users/rauchg/Projects/zeit/hyperterm/app
|
||||||
|
17 error Darwin 15.4.0
|
||||||
|
18 error argv "/usr/local/bin/node" "/usr/local/bin/npm" "run" "lint"
|
||||||
|
19 error node v6.2.1
|
||||||
|
20 error npm v3.9.3
|
||||||
|
21 error code ELIFECYCLE
|
||||||
|
22 error hyperterm-web@0.0.1 lint: `eslint *.js`
|
||||||
|
22 error Exit status 1
|
||||||
|
23 error Failed at the hyperterm-web@0.0.1 lint script 'eslint *.js'.
|
||||||
|
23 error Make sure you have the latest version of node.js and npm installed.
|
||||||
|
23 error If you do, this is most likely a problem with the hyperterm-web package,
|
||||||
|
23 error not with npm itself.
|
||||||
|
23 error Tell the author that this fails on your system:
|
||||||
|
23 error eslint *.js
|
||||||
|
23 error You can get information on how to open an issue for this project with:
|
||||||
|
23 error npm bugs hyperterm-web
|
||||||
|
23 error Or if that isn't available, you can get their info via:
|
||||||
|
23 error npm owner ls hyperterm-web
|
||||||
|
23 error There is likely additional logging output above.
|
||||||
|
24 verbose exit [ 1, true ]
|
||||||
57
app/package.json
Normal file
57
app/package.json
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
{
|
||||||
|
"name": "hyperterm-web",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"react-addons-shallow-compare": "15.1.0",
|
||||||
|
"mousetrap": "1.6.0",
|
||||||
|
"classnames": "2.2.5",
|
||||||
|
"react": "15.1.0",
|
||||||
|
"react-dom": "15.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "2.13.1",
|
||||||
|
"eslint-config-standard": "5.3.1",
|
||||||
|
"babel-eslint": "6.1.0",
|
||||||
|
"eslint-plugin-react": "5.2.2",
|
||||||
|
"babel-plugin-transform-es2015-modules-commonjs": "6.10.3",
|
||||||
|
"babel-cli": "6.10.1",
|
||||||
|
"babel-loader": "6.2.4",
|
||||||
|
"babel-core": "6.10.4",
|
||||||
|
"style-loader": "0.13.1",
|
||||||
|
"css-loader": "0.23.1",
|
||||||
|
"eslint-plugin-standard": "1.3.2",
|
||||||
|
"eslint-plugin-promise": "1.3.2"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "standard",
|
||||||
|
"plugins": [
|
||||||
|
"react"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"yoda": 0,
|
||||||
|
"semi": [2, "always"],
|
||||||
|
"no-unused-vars": 2,
|
||||||
|
"no-extra-semi": 2,
|
||||||
|
"semi-spacing": [2, { "before": false, "after": true }],
|
||||||
|
"react/jsx-uses-react": 1,
|
||||||
|
"react/jsx-uses-vars": 1
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"babel": {
|
||||||
|
"plugins": [
|
||||||
|
"transform-react-jsx",
|
||||||
|
"transform-es2015-modules-commonjs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "webpack --watch",
|
||||||
|
"lint": "eslint *.js",
|
||||||
|
"build": "NODE_ENV=production webpack"
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/rpc.js
Normal file
58
app/rpc.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
export default class Client {
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
const electron = window.require('electron');
|
||||||
|
const EventEmitter = window.require('events');
|
||||||
|
this.emitter = new EventEmitter();
|
||||||
|
this.ipc = electron.ipcRenderer;
|
||||||
|
this.ipcListener = this.ipcListener.bind(this);
|
||||||
|
if (window.__rpcId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.id = window.__rpcId;
|
||||||
|
this.ipc.on(this.id, this.ipcListener);
|
||||||
|
this.emitter.emit('ready');
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
this.ipc.on('init', (ev, uid) => {
|
||||||
|
// we cache so that if the object
|
||||||
|
// gets re-instantiated we don't
|
||||||
|
// wait for a `init` event
|
||||||
|
window.__rpcId = uid;
|
||||||
|
this.id = uid;
|
||||||
|
this.ipc.on(uid, this.ipcListener);
|
||||||
|
this.emitter.emit('ready');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcListener (event, { ch, data }) {
|
||||||
|
this.emitter.emit(ch, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
on (ev, fn) {
|
||||||
|
this.emitter.on(ev, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
once (ev, fn) {
|
||||||
|
this.emitter.once(ev, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit (ev, data) {
|
||||||
|
if (!this.id) throw new Error('Not ready');
|
||||||
|
this.ipc.send(this.id, { ev, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
removeListener (ev, fn) {
|
||||||
|
this.emitter.removeListener(ev, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners () {
|
||||||
|
this.emitter.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy () {
|
||||||
|
this.removeAllListeners();
|
||||||
|
this.ipc.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
31
app/tabs.js
Normal file
31
app/tabs.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from 'react';
|
||||||
|
import classes from 'classnames';
|
||||||
|
|
||||||
|
export default function ({ data = [], active, activeMarkers = {}, onChange }) {
|
||||||
|
return <nav style={{ WebkitAppRegion: 'drag' }}>{
|
||||||
|
data.length
|
||||||
|
? 1 === data.length
|
||||||
|
? <div className='single'>{ data[0] }</div>
|
||||||
|
: <ul className='tabs'>
|
||||||
|
{
|
||||||
|
data.map((tab, i) => {
|
||||||
|
const isActive = i === active;
|
||||||
|
const hasActivity = ~activeMarkers.indexOf(i);
|
||||||
|
return <li
|
||||||
|
key={`tab-${i}`}
|
||||||
|
className={classes({ is_active: isActive, has_activity: hasActivity })}
|
||||||
|
onClick={ onChange ? onClick.bind(null, i, onChange, active) : null }>
|
||||||
|
<span>{ tab }</span>
|
||||||
|
</li>;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
: null
|
||||||
|
}</nav>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick (i, onChange, active) {
|
||||||
|
if (i !== active) {
|
||||||
|
onChange(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/term.js
Normal file
97
app/term.js
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import Terminal from './xterm';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
} else {
|
||||||
|
this.term.ignoreKeyEvents = false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
write (data) {
|
||||||
|
const match = data.match(/bash: ((https?:\/\/)|(\/\/))?(.*): ((command not found)|(No such file or directory))/);
|
||||||
|
if (match) {
|
||||||
|
const url = match[4];
|
||||||
|
// extract the domain portion from the url
|
||||||
|
const domain = url.split('/')[0];
|
||||||
|
if (domainRegex.test(domain)) {
|
||||||
|
this.props.onURL(toURL(url));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.term.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
focus () {
|
||||||
|
this.term.element.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
this.term.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
return <div>
|
||||||
|
<div ref='term' />
|
||||||
|
{ this.props.url
|
||||||
|
? <webview
|
||||||
|
src={this.props.url}
|
||||||
|
style={{
|
||||||
|
background: '#000',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
display: 'inline-flex',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%'
|
||||||
|
}}></webview>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function toURL (domain) {
|
||||||
|
if (/^https?:\/\//.test(domain)) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('//' === domain.substr(0, 2)) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'http://' + domain;
|
||||||
|
}
|
||||||
20
app/text-metrics.js
Normal file
20
app/text-metrics.js
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
32
app/webpack.config.js
Normal file
32
app/webpack.config.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
const webpack = require('webpack');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||||
|
const isProd = nodeEnv === 'production';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
devtool: isProd ? 'hidden-source-map' : 'cheap-eval-source-map',
|
||||||
|
entry: './index.js',
|
||||||
|
output: {
|
||||||
|
path: path.join(__dirname, './dist'),
|
||||||
|
filename: 'bundle.js'
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
loaders: [
|
||||||
|
{
|
||||||
|
test: /\.(js|jsx)$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
loaders: [
|
||||||
|
'babel-loader'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
loader: 'style-loader!css-loader'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
new webpack.optimize.OccurrenceOrderPlugin()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
4834
app/xterm.js
Normal file
4834
app/xterm.js
Normal file
File diff suppressed because it is too large
Load diff
12
build.sh
Executable file
12
build.sh
Executable file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Electron's version.
|
||||||
|
export npm_config_target=1.2.3
|
||||||
|
# The architecture of Electron, can be ia32 or x64.
|
||||||
|
export npm_config_arch=x64
|
||||||
|
# Download headers for Electron.
|
||||||
|
export npm_config_disturl=https://atom.io/download/atom-shell
|
||||||
|
# Tell node-pre-gyp that we are building for Electron.
|
||||||
|
export npm_config_runtime=electron
|
||||||
|
# Tell node-pre-gyp to build module from source code.
|
||||||
|
export npm_config_build_from_source=true
|
||||||
|
# Install all dependencies, and store cache to ~/.electron-gyp.
|
||||||
|
HOME=~/.electron-gyp npm install
|
||||||
215
index.js
Normal file
215
index.js
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
const { app, BrowserWindow, Menu } = require('electron');
|
||||||
|
const createRPC = require('./rpc');
|
||||||
|
const Session = require('./session');
|
||||||
|
const genUid = require('uid2');
|
||||||
|
const { resolve } = require('path');
|
||||||
|
|
||||||
|
if ('development' === process.env.NODE_ENV) {
|
||||||
|
console.log('initializing in dev mode (NODE_ENV)');
|
||||||
|
} else {
|
||||||
|
console.log('initializing in prod mode (NODE_ENV)');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
// by subscribing to this event and nooping
|
||||||
|
// we prevent electron's default behavior
|
||||||
|
// of quitting the app when the last
|
||||||
|
// terminal is closed
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('ready', () => {
|
||||||
|
function createWindow (fn) {
|
||||||
|
let win = new BrowserWindow({
|
||||||
|
width: 540,
|
||||||
|
height: 380,
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
title: 'HyperTerm',
|
||||||
|
backgroundColor: '#000',
|
||||||
|
transparent: true,
|
||||||
|
// we only want to show when the prompt
|
||||||
|
// is ready for user input
|
||||||
|
show: 'development' === process.env.NODE_ENV
|
||||||
|
});
|
||||||
|
|
||||||
|
win.loadURL('file://' + resolve(__dirname, 'app', 'index.html'));
|
||||||
|
|
||||||
|
const rpc = createRPC(win);
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
rpc.on('init', () => {
|
||||||
|
win.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
rpc.on('new', ({ rows = 40, cols = 100 }) => {
|
||||||
|
initSession({ rows, cols }, (uid, session) => {
|
||||||
|
sessions.set(uid, session);
|
||||||
|
rpc.emit('new session', { uid });
|
||||||
|
|
||||||
|
session.on('data', (data) => {
|
||||||
|
rpc.emit('data', { uid, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
session.on('title', (title) => {
|
||||||
|
rpc.emit('title', { uid, title });
|
||||||
|
});
|
||||||
|
|
||||||
|
session.on('exit', () => {
|
||||||
|
rpc.emit('exit', { uid });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
rpc.on('focus', ({ uid }) => {
|
||||||
|
sessions.get(uid).focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
rpc.on('blur', ({ uid }) => {
|
||||||
|
sessions.get(uid).blur();
|
||||||
|
});
|
||||||
|
|
||||||
|
rpc.on('exit', ({ uid }) => {
|
||||||
|
sessions.get(uid).exit();
|
||||||
|
});
|
||||||
|
|
||||||
|
rpc.on('resize', ({ cols, rows }) => {
|
||||||
|
sessions.forEach((session) => {
|
||||||
|
session.resize({ cols, rows });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
rpc.on('data', ({ uid, data }) => {
|
||||||
|
sessions.get(uid).write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteSessions = () => {
|
||||||
|
sessions.forEach((session, key) => {
|
||||||
|
session.removeAllListeners();
|
||||||
|
session.destroy();
|
||||||
|
sessions.delete(key);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// we reset the rpc channel only upon
|
||||||
|
// subsequent refreshes (ie: F5)
|
||||||
|
let i = 0;
|
||||||
|
win.webContents.on('did-navigate', () => {
|
||||||
|
if (i++) {
|
||||||
|
deleteSessions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// the window can be closed by the browser process itself
|
||||||
|
win.on('close', () => {
|
||||||
|
rpc.destroy();
|
||||||
|
deleteSessions();
|
||||||
|
});
|
||||||
|
|
||||||
|
win.rpc = rpc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// when opening create a new window
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
// set menu
|
||||||
|
Menu.setApplicationMenu(Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: 'Application',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
role: 'quit'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Shell',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'New Window',
|
||||||
|
accelerator: 'CmdOrCtrl+N',
|
||||||
|
click (item, focusedWindow) {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New Tab',
|
||||||
|
accelerator: 'CmdOrCtrl+T',
|
||||||
|
click (item, focusedWindow) {
|
||||||
|
if (focusedWindow) {
|
||||||
|
focusedWindow.rpc.emit('new tab');
|
||||||
|
} else {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Close',
|
||||||
|
accelerator: 'CmdOrCtrl+W',
|
||||||
|
click (item, focusedWindow) {
|
||||||
|
if (focusedWindow) {
|
||||||
|
focusedWindow.rpc.emit('close tab');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
submenu: [
|
||||||
|
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' },
|
||||||
|
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' },
|
||||||
|
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', selector: 'selectAll:' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Reload',
|
||||||
|
accelerator: 'CmdOrCtrl+R',
|
||||||
|
click (item, focusedWindow) {
|
||||||
|
if (focusedWindow) focusedWindow.reload();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Toggle Developer Tools',
|
||||||
|
accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
|
||||||
|
click (item, focusedWindow) {
|
||||||
|
if (focusedWindow) {
|
||||||
|
focusedWindow.webContents.toggleDevTools();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Window',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Select Previous Tab',
|
||||||
|
accelerator: 'CmdOrCtrl+Left',
|
||||||
|
click (item, focusedWindow) {
|
||||||
|
if (focusedWindow) {
|
||||||
|
focusedWindow.rpc.emit('move left');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Select Next Tab',
|
||||||
|
accelerator: 'CmdOrCtrl+Right',
|
||||||
|
click (item, focusedWindow) {
|
||||||
|
if (focusedWindow) {
|
||||||
|
focusedWindow.rpc.emit('move right');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
function initSession (opts, fn) {
|
||||||
|
genUid(20, (err, uid) => {
|
||||||
|
if (err) throw err;
|
||||||
|
fn(uid, new Session(opts));
|
||||||
|
});
|
||||||
|
}
|
||||||
36
package.json
Normal file
36
package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "hyperterm",
|
||||||
|
"productName": "HyperTerm",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"electron-prebuilt": "1.2.5",
|
||||||
|
"electron-rpc": "^2.0.1",
|
||||||
|
"ptyw.js": "0.4.0",
|
||||||
|
"terminal.js": "1.0.7",
|
||||||
|
"uid-promise": "0.1.0",
|
||||||
|
"uid2": "0.0.3",
|
||||||
|
"child_pty": "3.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "2.13.1",
|
||||||
|
"eslint-config-standard": "5.3.1",
|
||||||
|
"eslint-plugin-standard": "1.3.2",
|
||||||
|
"eslint-plugin-promise": "1.3.2"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "standard",
|
||||||
|
"rules": {
|
||||||
|
"yoda": 0,
|
||||||
|
"semi": [2, "always"],
|
||||||
|
"no-unused-vars": 2,
|
||||||
|
"no-extra-semi": 2,
|
||||||
|
"semi-spacing": [2, { "before": false, "after": true }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next -p 3006 app",
|
||||||
|
"launch": "electron index",
|
||||||
|
"lint": "eslint *.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
69
rpc.js
Normal file
69
rpc.js
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const { ipcMain } = require('electron');
|
||||||
|
const genUid = require('uid2');
|
||||||
|
|
||||||
|
class Server {
|
||||||
|
|
||||||
|
constructor (win) {
|
||||||
|
this.win = win;
|
||||||
|
this.ipcListener = this.ipcListener.bind(this);
|
||||||
|
this.emitter = new EventEmitter();
|
||||||
|
genUid(10, (err, uid) => {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
if (err) return this.emitter.emit('error', err);
|
||||||
|
this.id = uid;
|
||||||
|
ipcMain.on(uid, this.ipcListener);
|
||||||
|
|
||||||
|
// we intentionally subscribe to `on` instead of `once`
|
||||||
|
// to support reloading the window and re-initializing
|
||||||
|
// the channel
|
||||||
|
this.wc.on('did-finish-load', () => {
|
||||||
|
this.wc.send('init', uid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get wc () {
|
||||||
|
return this.win.webContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcListener (event, { ev, data }) {
|
||||||
|
this.emitter.emit(ev, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit (ch, data) {
|
||||||
|
this.wc.send(this.id, { ch, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
on (ev, fn) {
|
||||||
|
this.emitter.on(ev, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
once (ev, fn) {
|
||||||
|
this.emitter.once(ev, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeListener (ev, fn) {
|
||||||
|
this.emitter.removeListener(ev, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners () {
|
||||||
|
this.emitter.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy () {
|
||||||
|
this.removeAllListeners();
|
||||||
|
this.wc.removeAllListeners();
|
||||||
|
if (this.id) {
|
||||||
|
ipcMain.removeListener(this.id, this.ipcListener);
|
||||||
|
} else {
|
||||||
|
// mark for `genUid` in constructor
|
||||||
|
this.destroyed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = function createRPC (win) {
|
||||||
|
return new Server(win);
|
||||||
|
};
|
||||||
96
session.js
Normal file
96
session.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const { spawn } = require('child_pty');
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
|
||||||
|
const TITLE_POLL_INTERVAL = 1000;
|
||||||
|
|
||||||
|
module.exports = class Session extends EventEmitter {
|
||||||
|
|
||||||
|
constructor ({ rows, cols }) {
|
||||||
|
super();
|
||||||
|
this.pty = spawn('bash', [], {
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
cwd: process.env.HOME,
|
||||||
|
env: Object.assign({}, process.env, {
|
||||||
|
TERM: 'xterm-256color'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pty.stdout.on('data', (data) => {
|
||||||
|
this.emit('data', data.toString('utf8'));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pty.on('exit', () => {
|
||||||
|
if (!this.ended) {
|
||||||
|
this.ended = true;
|
||||||
|
this.emit('exit');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.getTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
focus () {
|
||||||
|
this.getTitle(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
blur () {
|
||||||
|
clearTimeout(this.titlePoll);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTitle (subscribe = false) {
|
||||||
|
if ('win32' === process.platform) return;
|
||||||
|
|
||||||
|
let tty = this.pty.stdout.ttyname;
|
||||||
|
tty = tty.replace(/^\/dev\/tty/, '');
|
||||||
|
|
||||||
|
// try to exclude grep from the results
|
||||||
|
// by grepping for `[s]001` instead of `s001`
|
||||||
|
tty = `[${tty[0]}]${tty.substr(1)}`;
|
||||||
|
|
||||||
|
// TODO: limit the concurrency of how many processes we run?
|
||||||
|
// TODO: only tested on mac
|
||||||
|
exec(`ps ac | grep ${tty} | tail -n 1`, (err, out) => {
|
||||||
|
if (this.ended) return;
|
||||||
|
if (err) return;
|
||||||
|
let title = out.split(' ').pop();
|
||||||
|
if (title) {
|
||||||
|
title = title.replace(/^\(/, '');
|
||||||
|
title = title.replace(/\)?\n$/, '');
|
||||||
|
if (title !== this.lastTitle) {
|
||||||
|
this.emit('title', title);
|
||||||
|
this.lastTitle = title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscribe) {
|
||||||
|
this.titlePoll = setTimeout(() => this.getTitle(true), TITLE_POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
exit () {
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
write (data) {
|
||||||
|
this.pty.stdin.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize ({ cols: columns, rows }) {
|
||||||
|
try {
|
||||||
|
this.pty.stdout.resize({ columns, rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy () {
|
||||||
|
this.pty.kill('SIGHUP');
|
||||||
|
this.emit('exit');
|
||||||
|
this.ended = true;
|
||||||
|
clearTimeout(this.titlePoll);
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
Loading…
Reference in a new issue