This commit is contained in:
Guillermo Rauch 2016-07-13 13:44:24 -07:00
parent 8fac6bcec9
commit 477e40e433
58 changed files with 2751 additions and 1141 deletions

View file

@ -1,40 +0,0 @@
/* global Notification */
/* eslint no-new:0 */
import React from 'react';
import { ipcRenderer, remote } from 'electron';
const config = remote.require('./config');
export default class Config extends React.Component {
constructor () {
super();
this.state = {
config: config.getConfig()
};
this.onChange = this.onChange.bind(this);
}
onChange () {
this.setState({ config: config.getConfig() });
}
componentDidMount () {
ipcRenderer.on('config change', this.onChange);
ipcRenderer.on('plugins change', this.onChange);
}
// passes `config` as props to the decorated component
render () {
const child = React.Children.only(this.props.children);
const { config } = this.state;
const decorate = remote.require('./plugins').decorateConfig;
return React.cloneElement(child, { config: decorate(config) });
}
componentWillUnmount () {
ipcRenderer.removeListener('config change', this.onChange);
ipcRenderer.removeListener('plugins change', this.onChange);
}
}

View file

@ -1,105 +0,0 @@
.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;
}
.term {
display: none;
}
.term.active {
display: block;
}
.resize-indicator,
.update-indicator {
cursor: default;
-webkit-user-select: none;
}
.resize-indicator {
color: #fff;
font: 11px Menlo;
position: fixed;
bottom: 20px;
right: 20px;
opacity: 0;
transition: opacity 150ms ease-in;
display: flex;
}
.resize-indicator > div {
background: rgba(255, 255, 255, .2);
padding: 6px 14px;
margin-left: 10px;
}
.resize-indicator.showing {
opacity: 1;
}
.update-indicator {
background: #7ED321;
padding: 6px 14px;
color: #fff;
font: 11px Menlo;
position: fixed;
bottom: 20px;
right: 20px;
opacity: 0;
transition: opacity 150ms ease-in;
pointer-events: none;
}
.update-indicator a {
color: #fff;
}
.update-indicator .close {
color: #528D11;
cursor: pointer;
}
.update-indicator.showing {
opacity: 1;
pointer-events: inherit;
}
.icon {
vertical-align: middle;
fill: currentColor;
width: 1em;
height: 1em;
}

View file

@ -1,118 +0,0 @@
nav {
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans",
"Droid Sans", "Helvetica Neue", sans-serif;
height: 34px;
line-height: 34px;
vertical-align: middle;
color: #9B9B9B;
cursor: default;
-webkit-user-select: none;
}
.single {
text-align: center;
color: #fff;
}
.tabs {
border-bottom: 1px solid #333;
max-height: 34px;
display: flex;
flex-flow: row;
}
.tabs li:first-child {
margin-left: 76px;
}
.tabs li {
list-style-type: none;
flex-grow: 1;
text-align: center;
position: relative;
}
.tabs li.is_active {
color: #fff;
}
.tabs li span {
transition: color .2s ease;
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 span {
color: #ccc;
}
.tabs li.has_activity, .tabs li.has_activity:hover {
color: #50E3C2;
}
/* The close button */
.tabs li i {
transition: opacity .2s ease,
color .2s ease,
transform .25s ease,
background-color .1s ease;
pointer-events: none;
position: absolute;
right: 7px;
top: 8px;
display: inline-block;
width: 14px;
height: 14px;
border-radius: 100%;
color: #e9e9e9;
opacity: 0;
transform: scale(.95);
}
.tabs li:hover i {
opacity: 1;
transform: none;
pointer-events: all;
}
.tabs li i:hover {
background-color: rgba(255,255,255, .13);
color: #fff;
}
.tabs li i:active {
background-color: rgba(255,255,255, .1);
color: #909090;
}
.tabs li i .icon {
position: absolute;
left: 4px;
top: 4px;
width: 6px;
height: 6px;
}

View file

@ -1,532 +0,0 @@
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 decorate from './plugins';
// make subcomponents reload siwth plugin changes
const Tabs = decorate(Tabs_);
const Term = decorate(Term_);
export default class HyperTerm extends Component {
constructor (props) {
super();
this.state = {
cols: null,
rows: null,
hpadding: 12,
vpadding: 12,
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);
document.body.style.backgroundColor = props.config.backgroundColor;
}
componentWillReceiveProps (props) {
if (props.config.fontSize !== this.props.config.fontSize) {
this.changeFontSize(props.config.fontSize);
}
if (props.config.backgroundColor !== this.props.config.backgroundColor) {
document.body.style.backgroundColor = props.config.backgroundColor;
}
}
render () {
const { backgroundColor, borderColor, css } = this.props.config;
return <div onClick={ this.focusActive }>
<div style={{ borderColor }} className={ classes('main', { mac: this.state.mac }) }>
<header style={{ backgroundColor }} onMouseDown={this.onHeaderMouseDown}>
<Tabs
active={this.state.active}
activeMarkers={this.state.activeMarkers}
borderColor={borderColor}
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%' }}>
<Term
key={uid}
ref={`term-${uid}`}
cols={this.state.cols}
rows={this.state.rows}
customCSS={config.termCSS}
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>
<style dangerouslySetInnerHTML={{ __html: Array.from(css || '').join('\n') }} />
</div>;
}
dismissUpdate () {
this.setState({ dismissedUpdate: true });
}
quitAndInstall (ev) {
ev.preventDefault();
this.rpc.emit('quit and install');
}
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 && exit\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;
}
const ai2 = activeMarkers.indexOf(active);
if (~ai2) {
activeMarkers.splice(ai2, 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();
}
}

View file

@ -5,7 +5,6 @@
<meta name="viewport" content="initial-scale=1.0" />
<style>
body {
background: #000;
color: #fff;
}

View file

@ -1,14 +0,0 @@
import { render } from 'react-dom';
import HyperTerm_ from './hyperterm';
import React from 'react';
import Config from './config';
import decorate from './plugins';
// make the component reload with plugin changes
const HyperTerm = decorate(HyperTerm_);
require('./css/hyperterm.css');
require('./css/tabs.css');
const app = <Config><HyperTerm /></Config>;
render(app, document.getElementById('mount'));

15
app/lib/actions/config.js Normal file
View file

@ -0,0 +1,15 @@
import { CONFIG_LOAD, CONFIG_RELOAD } from '../constants/config';
export function loadConfig (config) {
return {
type: CONFIG_LOAD,
config
};
}
export function reloadConfig (config) {
return {
type: CONFIG_RELOAD,
config
};
}

26
app/lib/actions/header.js Normal file
View file

@ -0,0 +1,26 @@
import { CLOSE_TAB, CHANGE_TAB } from '../constants/tabs';
import { userExitSession, setActiveSession } from './sessions';
export function closeTab (uid) {
return (dispatch, getState) => {
dispatch({
type: CLOSE_TAB,
uid,
effect () {
dispatch(userExitSession(uid));
}
});
};
}
export function changeTab (uid) {
return (dispatch, getState) => {
dispatch({
type: CHANGE_TAB,
uid,
effect () {
dispatch(setActiveSession(uid));
}
});
};
}

7
app/lib/actions/index.js Normal file
View file

@ -0,0 +1,7 @@
import { requestSession } from './sessions';
export function init () {
return (dispatch) => {
dispatch(requestSession());
};
}

View file

@ -0,0 +1,8 @@
import { NOTIFICATION_DISMISS } from '../constants/notifications';
export function dismissNotification (id) {
return {
type: NOTIFICATION_DISMISS,
id
};
}

182
app/lib/actions/sessions.js Normal file
View file

@ -0,0 +1,182 @@
import rpc from '../rpc';
import getURL from '../utils/url-command';
import { keys } from '../utils/object';
import {
SESSION_ADD,
SESSION_RESIZE,
SESSION_REQUEST,
SESSION_ADD_DATA,
SESSION_PTY_DATA,
SESSION_PTY_EXIT,
SESSION_USER_EXIT,
SESSION_USER_EXIT_ACTIVE,
SESSION_SET_ACTIVE,
SESSION_CLEAR_ACTIVE,
SESSION_USER_DATA,
SESSION_URL_SET,
SESSION_URL_UNSET,
SESSION_SET_XTERM_TITLE,
SESSION_SET_PROCESS_TITLE
} from '../constants/sessions';
export function addSession (uid) {
return {
type: SESSION_ADD,
uid
};
}
export function requestSession (uid) {
return (dispatch, getState) => {
const { ui } = getState();
const cols = ui.cols;
const rows = ui.rows;
dispatch({
type: SESSION_REQUEST,
effect: () => {
rpc.emit('new', { cols, rows });
}
});
};
}
export function addSessionData (uid, data) {
return (dispatch, getState) => {
dispatch({
type: SESSION_ADD_DATA,
data,
effect () {
const url = getURL(data);
if (null != url) {
dispatch({
type: SESSION_URL_SET,
uid,
url
});
} else {
dispatch({
type: SESSION_PTY_DATA,
uid,
data
});
}
}
});
};
}
export function sessionExit (uid) {
return (dispatch, getState) => {
return dispatch({
type: SESSION_PTY_EXIT,
uid,
effect () {
// we reiterate the same logic as below
// for SESSION_USER_EXIT since the exit
// could happen pty side or optimistic
const sessions = keys(getState().sessions.sessions);
if (!sessions.length) {
window.close();
}
}
});
};
}
// we want to distinguish an exit
// that's UI initiated vs pty initiated
export function userExitSession (uid) {
return (dispatch, getState) => {
return dispatch({
type: SESSION_USER_EXIT,
uid,
effect () {
rpc.emit('exit', { uid });
const sessions = keys(getState().sessions.sessions);
if (!sessions.length) {
window.close();
}
}
});
};
}
export function userExitActiveSession () {
return (dispatch, getState) => {
dispatch({
type: SESSION_USER_EXIT_ACTIVE,
effect () {
const uid = getState().sessions.activeUid;
dispatch(userExitSession(uid));
}
});
};
}
export function setActiveSession (uid) {
return (dispatch, getState) => {
const state = getState();
const prevUid = state.sessions.activeUid;
dispatch({
type: SESSION_SET_ACTIVE,
uid,
effect () {
// TODO: this goes away when we are able to poll
// for the title ourseleves, instead of relying
// on Session and focus/blur to subscribe
if (prevUid) rpc.emit('blur', { uid: prevUid });
rpc.emit('focus', { uid });
}
});
};
}
export function clearActiveSession () {
return {
type: SESSION_CLEAR_ACTIVE
};
}
export function setSessionProcessTitle (uid, title) {
return {
type: SESSION_SET_PROCESS_TITLE,
uid,
title
};
}
export function setSessionXtermTitle (uid, title) {
return {
type: SESSION_SET_XTERM_TITLE,
uid,
title
};
}
export function resizeSession (uid, cols, rows) {
return {
type: SESSION_RESIZE,
cols,
rows,
effect () {
rpc.emit('resize', { cols, rows });
}
};
}
export function sendSessionData (uid, data) {
return {
type: SESSION_USER_DATA,
data,
effect () {
rpc.emit('data', { uid, data });
}
};
}
export function exitSessionBrowser (uid) {
return {
type: SESSION_URL_UNSET,
uid
};
}

143
app/lib/actions/ui.js Normal file
View file

@ -0,0 +1,143 @@
import { setActiveSession } from './sessions';
import { keys } from '../utils/object';
import { last } from '../utils/array';
import rpc from '../rpc';
import {
requestSession,
sendSessionData
} from '../actions/sessions';
import {
UI_FONT_SIZE_SET,
UI_FONT_SIZE_INCR,
UI_FONT_SIZE_DECR,
UI_FONT_SIZE_RESET,
UI_MOVE_LEFT,
UI_MOVE_RIGHT,
UI_MOVE_TO,
UI_SHOW_PREFERENCES
} from '../constants/ui';
export function increaseFontSize () {
return (dispatch, getState) => {
dispatch({
type: UI_FONT_SIZE_INCR,
effect () {
const state = getState();
const old = state.ui.fontSizeOverride || state.ui.fontSize;
const size = old + 1;
dispatch({
type: UI_FONT_SIZE_SET,
size
});
}
});
};
}
export function decreaseFontSize () {
return (dispatch, getState) => {
dispatch({
type: UI_FONT_SIZE_DECR,
effect () {
const state = getState();
const old = state.ui.fontSizeOverride || state.ui.fontSize;
const size = old + 1;
dispatch({
type: UI_FONT_SIZE_SET,
size
});
}
});
};
}
export function resetFontSize () {
return {
type: UI_FONT_SIZE_RESET
};
}
export function moveLeft () {
return (dispatch, getState) => {
dispatch({
type: UI_MOVE_LEFT,
effect () {
const { sessions } = getState();
const uid = sessions.activeUid;
const sessionUids = keys(sessions.sessions);
const index = sessionUids.indexOf(uid);
const next = sessionUids[index - 1] || last(sessionUids);
if (!next || uid === next) {
console.log('ignoring left move action');
} else {
dispatch(setActiveSession(next));
}
}
});
};
}
export function moveRight () {
return (dispatch, getState) => {
dispatch({
type: UI_MOVE_RIGHT,
effect () {
const { sessions } = getState();
const uid = sessions.activeUid;
const sessionUids = keys(sessions.sessions);
const index = sessionUids.indexOf(uid);
const next = sessionUids[index + 1] || sessionUids[0];
if (!next || uid === next) {
console.log('ignoring right move action');
} else {
dispatch(setActiveSession(next));
}
}
});
};
}
export function moveTo (i) {
return (dispatch, getState) => {
dispatch({
type: UI_MOVE_TO,
index: i,
effect () {
const { sessions } = getState();
const uid = sessions.activeUid;
const sessionUids = keys(sessions.sessions);
if (uid === sessionUids[i]) {
console.log('ignoring same uid');
} else if (null != sessionUids[i]) {
dispatch(setActiveSession(sessionUids[i]));
} else {
console.log('ignoring inexistent index', i);
}
}
});
};
}
export function showPreferences () {
return (dispatch, getState) => {
dispatch({
type: UI_SHOW_PREFERENCES,
effect () {
dispatch(requestSession());
// TODO: replace this hack with an async action
rpc.once('session add', ({ uid }) => {
rpc.once('session data', () => {
dispatch(sendSessionData(
uid,
['# Attempting to open ~/.hyperterm.js with your $EDITOR',
'# If this doesn\'t work, open it manually with your favorite editor!',
'$EDITOR ~/.hyperterm.js && exit',
''
].join('\n')
));
});
});
}
});
};
}

View file

@ -0,0 +1,22 @@
import {
UPDATE_INSTALL,
UPDATE_AVAILABLE
} from '../constants/updater';
import rpc from '../rpc';
export function installUpdate () {
return {
type: UPDATE_INSTALL,
effect: () => {
rpc.emit('quit and install');
}
};
}
export function updateAvailable (version, notes) {
return {
type: UPDATE_AVAILABLE,
version,
notes
};
}

70
app/lib/component.js Normal file
View file

@ -0,0 +1,70 @@
import React from 'react';
import { StyleSheet, css } from 'aphrodite';
import { shouldComponentUpdate } from 'react-addons-pure-render-mixin';
export default class Component extends React.Component {
constructor () {
super();
this.styles_ = this.createStyleSheet();
this.cssHelper = this.cssHelper.bind(this);
if (!this.shouldComponentUpdate) {
this.shouldComponentUpdate = shouldComponentUpdate.bind(this);
}
}
createStyleSheet () {
if (!this.styles) {
return {};
}
const styles = this.styles();
if ('object' !== typeof styles) {
throw new TypeError('Component `styles` returns a non-object');
}
return StyleSheet.create(this.styles());
}
// wrap aphrodite's css helper for two reasons:
// - we can give the element an unaltered global classname
// that can be used to introduce global css side effects
// for example, through the configuration, web inspector
// or user agente extensions
// - the user doesn't need to keep track of both `css`
// and `style`, and we make that whole ordear easier
cssHelper (...args) {
const classes = args
.map((c) => {
if (c) {
// we compute the global name from the given
// css class and we prepend the component name
// it's important classes never get mangled by
// uglifiers so that we can avoid collisions
const component = this.constructor.name
.toString()
.toLowerCase();
const globalName = `${component}_${c}`;
return [globalName, css(this.styles_[c])];
}
})
// skip nulls
.filter((v) => !!v)
// flatten
.reduce((a, b) => a.concat(b));
return classes.length ? classes.join(' ') : null;
}
render () {
// convert static objects from `babel-plugin-transform-jsx`
// to `React.Element`.
if (!this.template) {
throw new TypeError("Component doesn't define `template`");
}
// invoke the template creator passing our css helper
return this.template(this.cssHelper);
}
}

View file

@ -0,0 +1,92 @@
import React from 'react';
import Tabs_ from './tabs';
import overrideStyles from '../utils/override-style';
import Component from '../component';
import { decorate, getTabsProps } from '../utils/plugins';
const Tabs = decorate(Tabs_, 'Tabs');
export default class Header extends Component {
constructor () {
super();
this.onChangeIntent = this.onChangeIntent.bind(this);
this.onHeaderMouseDown = this.onHeaderMouseDown.bind(this);
}
onChangeIntent (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.props.onChangeTab(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 () {
delete this.clicks;
clearTimeout(this.clickTimer);
}
template (css) {
const { isMac, backgroundColor } = this.props;
const props = getTabsProps(this.props, {
tabs: this.props.tabs,
borderColor: this.props.borderColor,
onClose: this.props.onCloseTab,
onChange: this.onChangeIntent
});
return <header
ref={ overrideStyles({ backgroundColor }) }
className={ css('header', isMac && 'headerRounded') }
onMouseDown={ this.onHeaderMouseDown }>
{ this.props.customChildrenBefore }
<Tabs {...props} />
{ this.props.customChildren }
</header>;
}
styles () {
return {
header: {
position: 'fixed',
top: '1px',
left: '1px',
right: '1px',
background: '#000',
zIndex: '100'
},
headerRounded: {
borderTopLeftRadius: '6px',
borderTopRightRadius: '6px'
}
};
}
}

View file

@ -0,0 +1,116 @@
import React from 'react';
import Component from '../component';
export default class Notification extends Component {
constructor () {
super();
this.state = {
dismissing: false
};
this.dismiss = this.dismiss.bind(this);
this.onElement = this.onElement.bind(this);
}
componentDidMount () {
if (this.props.dismissAfter) {
this.setDismissTimer();
}
}
componentWillReceiveProps (next) {
// if we have a timer going and the notification text
// changed we rest the timer
if (next.text !== this.props.text) {
if (this.props.dismissAfter) {
this.resetDismissTimer();
}
if (this.state.dismissing) {
this.setState({ dismissing: false });
}
}
}
dismiss () {
this.setState({ dismissing: true });
}
onElement (el) {
if (el) {
el.addEventListener('webkitTransitionEnd', () => {
if (this.state.dismissing) {
this.props.onDismiss();
}
});
// aprhodie !important override :(
const { backgroundColor } = this.props;
if (backgroundColor) {
el.style.setProperty(
'background-color',
backgroundColor,
'important'
);
}
}
}
setDismissTimer (after) {
this.dismissTimer = setTimeout(() => {
this.dismiss();
}, this.props.dismissAfter);
}
resetDismissTimer (after) {
clearTimeout(this.dismissTimer);
this.setDismissTimer();
}
componentWillUnmount () {
clearTimeout(this.dismissTimer);
}
template (css) {
const { backgroundColor } = this.props;
const opacity = this.state.dismissing ? 0 : 1;
return <div
style={{ opacity, backgroundColor }}
ref={ this.onElement }
className={ css('indicator') }>
{ this.props.customChildrenBefore }
{ this.props.children || this.props.text }
{
this.props.userDismissable
? <a
className={ css('dismissLink') }
onClick={ this.dismiss }>[x]</a>
: null
}
{ this.props.customChildren }
</div>;
}
styles () {
return {
indicator: {
display: 'inline-block',
cursor: 'default',
WebkitUserSelect: 'none',
background: 'rgba(255, 255, 255, .2)',
padding: '6px 14px',
marginLeft: '10px',
transition: '150ms opacity ease',
color: '#fff',
font: '11px Menlo'
},
dismissLink: {
cursor: 'pointer',
color: '#528D11',
':hover': {
color: '#2A5100'
}
}
};
}
}

View file

@ -0,0 +1,70 @@
import React from 'react';
import Component from '../component';
import Notification_ from './notification';
import { decorate } from '../utils/plugins';
const Notification = decorate(Notification_);
export default class Notifications extends Component {
template (css) {
return <div className={ css('view') }>
{ this.props.customChildrenBefore }
{
this.props.fontShowing &&
<Notification
key='font'
backgroundColor='rgba(255, 255, 255, .2)'
text={`${this.props.fontSize}px`}
userDismissable={ false }
onDismiss={ this.props.onDismissFont }
dismissAfter={ 1000 } />
}
{
this.props.resizeShowing &&
<Notification
key='resize'
backgroundColor='rgba(255, 255, 255, .2)'
text={`${this.props.cols}x${this.props.rows}`}
userDismissable={ false }
onDismiss={ this.props.onDismissResize }
dismissAfter={ 1000 } />
}
{
this.props.updateShowing &&
<Notification
key='update'
backgroundColor='#7ED321'
text={`Version ${this.props.updateVersion} available`}
onDismiss={ this.props.onDismissUpdate }
userDismissable={ true }>
Version <b>{ this.props.updateVersion}</b> available.
{ this.props.updateNote && ` ${this.props.updateNote.trim().replace(/\.$/, '')}.` }
{ ' ' }
<a style={{
cursor: 'pointer',
textDecoration: 'underline',
fontWeight: 'bold' }}
onClick={ this.props.onUpdateInstall }>
Restart
</a>
{ ' ' }
</Notification>
}
{ this.props.customChildren }
</div>;
}
styles () {
return {
view: {
position: 'fixed',
bottom: '20px',
right: '20px'
}
};
}
}

175
app/lib/components/tab.js Normal file
View file

@ -0,0 +1,175 @@
import React from 'react';
import Component from '../component';
import overrideStyle from '../utils/override-style';
export default class Tab extends Component {
constructor () {
super();
this.hover = this.hover.bind(this);
this.blur = this.blur.bind(this);
this.state = {
hovered: false
};
}
hover () {
this.setState({ hovered: true });
}
blur () {
this.setState({ hovered: false });
}
template (css) {
const { isActive, isFirst, isLast, borderColor, hasActivity } = this.props;
const { hovered } = this.state;
return <li
onMouseEnter={ this.hover }
onMouseLeave={ this.blur }
onClick={ this.props.onClick }
ref={ overrideStyle({ borderColor }) }
className={ css(
'tab',
isFirst && 'first',
isActive && 'active',
hasActivity && 'hasActivity'
) }>
{ this.props.customChildrenBefore }
<span
className={ css(
'text',
isLast && 'textLast',
isActive && 'textActive'
) }
onClick={ this.props.isActive ? null : this.props.onSelect }>
<span className={ css('textInner') }>
{ this.props.text }
</span>
</span>
<i
className={ css(
'icon',
hovered && 'iconHovered'
) }
onClick={ this.props.onClose }>
<svg className={ css('shape') }>
<use xlinkHref='assets/icons.svg#close'></use>
</svg>
</i>
{ this.props.customChildren }
</li>;
}
styles () {
return {
tab: {
color: '#ccc',
listStyleType: 'none',
flexGrow: 1,
position: 'relative',
':hover': {
color: '#ccc'
}
},
first: {
marginLeft: '76px'
},
active: {
color: '#fff',
':before': {
position: 'absolute',
content: '" "',
borderBottom: '1px solid #000',
display: 'block',
left: '0px',
right: '0px',
bottom: '-1px'
},
':hover': {
color: '#fff'
}
},
hasActivity: {
color: '#50E3C2',
':hover': {
color: '#50E3C2'
}
},
text: {
transition: 'color .2s ease',
height: '34px',
display: 'block',
width: '100%',
position: 'relative',
borderLeft: '1px solid transparent',
borderRight: '1px solid transparent',
overflow: 'hidden'
},
textInner: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
textAlign: 'center'
},
textLast: {
borderRightWidth: 0
},
textActive: {
borderColor: 'inherit'
},
icon: {
transition: `opacity .2s ease, color .2s ease,
transform .25s ease, background-color .1s ease`,
pointerEvents: 'none',
position: 'absolute',
right: '7px',
top: '10px',
display: 'inline-block',
width: '14px',
height: '14px',
borderRadius: '100%',
color: '#e9e9e9',
opacity: 0,
transform: 'scale(.95)',
':hover': {
backgroundColor: 'rgba(255,255,255, .13)',
color: '#fff'
},
':active': {
backgroundColor: 'rgba(255,255,255, .1)',
color: '#909090'
}
},
iconHovered: {
opacity: 1,
transform: 'none',
pointerEvents: 'all'
},
shape: {
position: 'absolute',
left: '4px',
top: '4px',
width: '6px',
height: '6px',
verticalAlign: 'middle',
fill: 'currentColor'
}
};
}
}

View file

@ -0,0 +1,81 @@
import Tab_ from './tab';
import React from 'react';
import Component from '../component';
import overrideStyle from '../utils/override-style';
import { decorate, getTabProps } from '../utils/plugins';
const Tab = decorate(Tab_, 'Tab');
export default class Tabs extends Component {
template (css) {
const {
tabs = [],
borderColor,
onChange,
onClose
} = this.props;
return <nav className={ css('nav') }>
{
tabs.length
? 1 === tabs.length
? <div className={ css('title') }>{ tabs[0].title }</div>
: <ul
ref={overrideStyle({ borderColor })}
className={ css('list') }>
{
tabs.map((tab, i) => {
const { uid, title, isActive, hasActivity } = tab;
const props = getTabProps(tab, this.props, {
text: '' === title ? 'Shell' : title,
isFirst: 0 === i,
isLast: tabs.length - 1 === i,
borderColor: borderColor,
isActive,
hasActivity,
onSelect: onChange.bind(null, uid),
onClose: onClose.bind(null, uid)
});
return <Tab key={`tab-${uid}`} {...props} />;
})
}
</ul>
: null
}
{ this.props.customChildren }
</nav>;
}
styles () {
return {
nav: {
fontSize: '12px',
fontFamily: `-apple-system, BlinkMacSystemFont,
"Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans",
"Droid Sans", "Helvetica Neue", sans-serif`,
height: '34px',
lineHeight: '34px',
verticalAlign: 'middle',
color: '#9B9B9B',
cursor: 'default',
WebkitUserSelect: 'none',
WebkitAppRegion: 'drag'
},
title: {
textAlign: 'center',
color: '#fff'
},
list: {
borderBottom: '1px solid #333',
maxHeight: '34px',
display: 'flex',
flexFlow: 'row'
}
};
}
}

View file

@ -1,17 +1,26 @@
/*global URL:false,Blob:false*/
import React, { Component } from 'react';
import { hterm, lib as htermLib } from './hterm';
import * as regex from './regex';
/* global Blob,URL */
import React from 'react';
import hterm from '../hterm';
import Component from '../component';
export default class Term extends Component {
constructor (props) {
super(props);
this.state = { scrollable: false };
this.onWheel = this.onWheel.bind(this);
this.onScrollEnter = this.onScrollEnter.bind(this);
this.onScrollLeave = this.onScrollLeave.bind(this);
props.ref_(this);
}
componentDidMount () {
const { props } = this;
this.term = new hterm.Terminal();
// the first term that's created has unknown size
// subsequent new tabs have size
if (props.cols) {
if (props.cols && props.rows) {
this.term.realizeSize_(props.cols, props.rows);
}
@ -19,22 +28,60 @@ export default class Term extends Component {
this.term.prefs_.set('font-size', props.fontSize);
this.term.prefs_.set('cursor-color', props.cursorColor);
this.term.prefs_.set('enable-clipboard-notice', false);
this.term.prefs_.set('foreground-color', props.foregroundColor);
this.term.prefs_.set('background-color', props.backgroundColor);
this.term.prefs_.set('color-palette-overrides', props.colors);
this.term.prefs_.set('user-css', this.getStylesheet(props.customCSS));
this.term.prefs_.set('scrollbar-visible', false);
this.term.onTerminalReady = () => {
const io = this.term.io.push();
io.onVTKeystroke = io.sendString = (str) => {
props.onData(str);
};
io.onVTKeystroke = io.sendString = props.onData;
io.onTerminalResize = (cols, rows) => {
props.onResize({ cols, rows });
if (cols !== this.props.cols && rows !== this.props.rows) {
props.onResize(cols, rows);
}
};
};
this.term.decorate(this.refs.term);
this.term.installKeyboard();
if (this.props.onTerminal) this.props.onTerminal(this.term);
const iframeWindow = this.getTermDocument().defaultView;
iframeWindow.addEventListener('wheel', this.onWheel);
}
onWheel () {
this.term.prefs_.set('scrollbar-visible', true);
clearTimeout(this.scrollbarsHideTimer);
if (!this.scrollMouseEnter) {
this.scrollbarsHideTimer = setTimeout(() => {
this.term.prefs_.set('scrollbar-visible', false);
}, 1000);
}
}
onScrollEnter () {
clearTimeout(this.scrollbarsHideTimer);
this.term.prefs_.set('scrollbar-visible', true);
this.scrollMouseEnter = true;
}
onScrollLeave () {
this.term.prefs_.set('scrollbar-visible', false);
this.scrollMouseEnter = false;
}
write (data) {
this.term.io.print(data);
}
focus () {
this.term.focus();
}
clear () {
this.term.clearPreserveCursorRow();
}
getTermDocument () {
@ -46,38 +93,12 @@ export default class Term extends Component {
.cursor-node[focus="false"] {
border-width: 1px !important;
}
${Array.from(css || '').join('\n')}
${css}
`]);
return URL.createObjectURL(blob, { type: 'text/css' });
}
componentWillReceiveProps (nextProps) {
if (this.props.fontSize !== nextProps.fontSize) {
this.term.prefs_.set('font-size', nextProps.fontSize);
}
if (this.props.backgroundColor !== nextProps.backgroundColor) {
this.term.prefs_.set('background-color', nextProps.backgroundColor);
}
if (this.props.fontFamily !== nextProps.fontFamily) {
this.term.prefs_.set('font-family', nextProps.fontFamily);
}
if (this.props.cursorColor !== nextProps.cursorColor) {
this.term.prefs_.set('cursor-color', nextProps.cursorColor);
}
if (this.props.colors.toString() !== nextProps.colors.toString()) {
this.term.prefs_.set('color-palette-overrides', nextProps.colors);
}
if (this.props.customCSS.toString() !== nextProps.customCSS.toString()) {
this.term.prefs_.set('user-css', this.getStylesheet(nextProps.customCSS));
}
}
shouldComponentUpdate (nextProps) {
if (this.props.url !== nextProps.url) {
// when the url prop changes, we make sure
// the terminal starts or stops ignoring
@ -93,100 +114,52 @@ export default class Term extends Component {
} else {
this.term.io.pop();
}
return true;
}
return false;
}
write (data) {
let match = data.match(regex.bash);
let url;
if (match) {
url = match[5];
} else {
match = data.match(regex.zsh);
if (match) {
url = match[7];
} else {
match = data.match(regex.fish);
if (match) {
url = match[4];
}
}
if (!this.props.cleared && nextProps.cleared) {
this.clear();
}
if (url) {
// extract the domain portion from the url
const domain = url.split('/')[0];
if (regex.domain.test(domain)) {
this.props.onURL(toURL(url));
return;
}
if (this.props.fontSize !== nextProps.fontSize) {
this.term.prefs_.set('font-size', nextProps.fontSize);
}
this.term.io.print(data);
}
if (this.props.foregroundColor !== nextProps.foregroundColor) {
this.term.prefs_.set('foreground-color', nextProps.foregroundColor);
}
focus () {
this.term.focus();
}
if (this.props.backgroundColor !== nextProps.backgroundColor) {
this.term.prefs_.set('background-color', nextProps.backgroundColor);
}
clear () {
const { term } = this;
if (this.props.fontFamily !== nextProps.fontFamily) {
this.term.prefs_.set('font-family', nextProps.fontFamily);
}
// 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();
if (this.props.cursorColor !== nextProps.cursorColor) {
this.term.prefs_.set('cursor-color', nextProps.cursorColor);
}
[term.primaryScreen_, term.alternateScreen_].forEach(function (screen) {
const bottom = screen.getHeight();
if (bottom > 0) {
term.renumberRows_(0, bottom);
if (this.props.colors !== nextProps.colors) {
this.term.prefs_.set('color-palette-overrides', nextProps.colors);
}
const x = screen.cursorPosition.column;
const y = screen.cursorPosition.row;
if (x === 0) {
// Empty screen, nothing to do.
return;
}
// here we move the row that the user was focused on
// to the top of the screen
term.moveRows_(y, 1, 0);
for (let i = 1; i < bottom; i++) {
screen.setCursorPosition(i, 0);
screen.clearCursorRow();
}
// 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_();
if (this.props.customCSS !== nextProps.customCSS) {
this.term.prefs_.set('user-css', this.getStylesheet(nextProps.customCSS));
}
}
componentWillUnmount () {
// there's no need to manually destroy
// as all the events are attached to the iframe
// which gets removed
const iframeWindow = this.getTermDocument().defaultView;
iframeWindow.addEventListener('wheel', this.onWheel);
clearTimeout(this.scrollbarsHideTimer);
this.props.ref_(null);
}
render () {
return <div style={{ width: '100%', height: '100%' }}>
<div ref='term' style={{ position: 'relative', width: '100%', height: '100%' }} />
template (css) {
return <div className={ css('fit') }>
{ this.props.customChildrenBefore }
<div ref='term' className={ css('fit', 'term') } />
{ this.props.url
? <webview
src={this.props.url}
@ -201,19 +174,33 @@ export default class Term extends Component {
}}></webview>
: null
}
<div
className={ css('scrollbarShim') }
onMouseEnter={ this.onScrollEnter }
onMouseLeave={ this.onScrollLeave } />
{ this.props.customChildren }
</div>;
}
}
styles () {
return {
fit: {
width: '100%',
height: '100%'
},
function toURL (domain) {
if (/^https?:\/\//.test(domain)) {
return domain;
term: {
position: 'relative'
},
scrollbarShim: {
position: 'fixed',
right: 0,
width: '50px',
top: 0,
bottom: 0
}
};
}
if ('//' === domain.substr(0, 2)) {
return domain;
}
return 'http://' + domain;
}

182
app/lib/components/terms.js Normal file
View file

@ -0,0 +1,182 @@
import React from 'react';
import Term_ from './term';
import Component from '../component';
import { shouldComponentUpdate } from 'react-addons-pure-render-mixin';
import { last } from '../utils/array';
import { decorate, getTermProps } from '../utils/plugins';
const Term = decorate(Term_, 'Term');
export default class Terms extends Component {
constructor (props, context) {
super(props, context);
this.terms = {};
this.bound = new WeakMap();
this.onRef = this.onRef.bind(this);
props.ref_(this);
}
componentWillReceiveProps (next) {
const { write } = next;
if (write && this.props.write !== write) {
this.getTermByUid(write.uid).write(write.data);
}
// if we just rendered, we consider the first tab active
// why is this decided here? because what session becomes
// active is a *view* and *layout* concern. for example,
// if a split is closed (and we had split), the next active
// session after the close would be the one next to it
// *in the view*, not necessarily the model datastructure
if (next.sessions && next.sessions.length) {
if (!this.props.activeSession && next.sessions.length) {
this.props.onActive(next.sessions[0].uid);
} else if (this.props.sessions.length !== next.sessions.length) {
if (next.sessions.length > this.props.sessions.length) {
// if we are adding, we focused on the new one
this.props.onActive(last(next.sessions).uid);
return;
}
const newUids = uids(next.sessions);
const curActive = this.props.activeSession;
// if we closed an item that wasn't focused, nothing changes
if (~newUids.indexOf(curActive)) {
return;
}
const oldIndex = uids(this.props.sessions).indexOf(curActive);
if (newUids[oldIndex]) {
this.props.onActive(newUids[oldIndex]);
} else {
this.props.onActive(last(next.sessions).uid);
}
}
} else {
this.props.onActive(null);
}
}
shouldComponentUpdate (nextProps, nextState) {
let nextProps_ = nextProps;
if (this.props.write || nextProps.write) {
// ignore `write` when performing the comparison
nextProps_ = Object.assign({}, nextProps, { write: null });
}
return shouldComponentUpdate.call(this, nextProps_);
}
onRef (uid, term) {
if (term) {
this.terms[uid] = term;
} else {
delete this.terms[uid];
}
}
getTermByUid (uid) {
return this.terms[uid];
}
getActiveTerm () {
return this.getTermByUid(this.props.activeSession);
}
bind (fn, thisObj, uid) {
if (!this.bound.has(fn)) {
this.bound.set(fn, {});
}
const map = this.bound.get(fn);
if (!map[uid]) {
map[uid] = fn.bind(thisObj, uid);
}
return map[uid];
}
getTermProps (uid) {
return getTermProps(uid, this.props);
}
onTerminal (uid, term) {
this.terms[uid] = term;
}
componentWillUnmount () {
this.props.ref_(null);
}
template (css) {
return <div
style={{ padding: this.props.padding }}
className={ css('terms') }>
{ this.props.customChildrenBefore }
{
this.props.sessions.map((session) => {
const uid = session.uid;
const isActive = uid === this.props.activeSession;
const props = getTermProps(uid, this.props, {
cols: this.props.cols,
rows: this.props.rows,
customCSS: this.props.customCSS,
fontSize: this.props.fontSize,
cursorColor: this.props.cursorColor,
fontFamily: this.props.fontFamily,
foregroundColor: this.props.foregroundColor,
backgroundColor: this.props.backgroundColor,
colors: this.props.colors,
url: session.url,
cleared: session.cleared,
onResize: this.bind(this.props.onResize, null, uid),
onTitle: this.bind(this.props.onTitle, null, uid),
onData: this.bind(this.props.onData, null, uid),
onURLAbort: this.bind(this.props.onURLAbort, null, uid)
});
return <div
key={`d${uid}`}
className={css('term', isActive && 'termActive')}>
<Term
key={uid}
ref_={this.bind(this.onRef, this, uid)}
{...props} />
</div>;
})
}
{ this.props.customChildren }
</div>;
}
styles () {
return {
terms: {
position: 'absolute',
marginTop: '34px',
top: 0,
right: 0,
left: 0,
bottom: 0,
color: '#fff'
},
term: {
display: 'none',
width: '100%',
height: '100%'
},
termActive: {
display: 'block'
}
};
}
}
// little memoized helper to compute a map of uids
function uids (sessions) {
if (!sessions._uids) {
sessions._uids = sessions.map((s) => s.uid);
}
return sessions._uids;
}

View file

@ -0,0 +1,2 @@
export const CONFIG_LOAD = 'CONFIG_LOAD';
export const CONFIG_RELOAD = 'CONFIG_RELOAD';

View file

@ -0,0 +1 @@
export const INIT = 'INIT';

View file

@ -0,0 +1 @@
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';

View file

@ -0,0 +1,15 @@
export const SESSION_ADD = 'SESSION_ADD';
export const SESSION_RESIZE = 'SESSION_RESIZE';
export const SESSION_REQUEST = 'SESSION_REQUEST';
export const SESSION_ADD_DATA = 'SESSION_ADD_DATA';
export const SESSION_PTY_DATA = 'SESSION_PTY_DATA';
export const SESSION_PTY_EXIT = 'SESSION_PTY_EXIT';
export const SESSION_USER_EXIT = 'SESSION_USER_EXIT';
export const SESSION_USER_EXIT_ACTIVE = 'SESSION_USER_EXIT_ACTIVE';
export const SESSION_SET_ACTIVE = 'SESSION_SET_ACTIVE';
export const SESSION_CLEAR_ACTIVE = 'SESSION_CLEAR_ACTIVE';
export const SESSION_USER_DATA = 'SESSION_USER_DATA';
export const SESSION_URL_SET = 'SESSION_URL_SET';
export const SESSION_URL_UNSET = 'SESSION_URL_UNSET';
export const SESSION_SET_XTERM_TITLE = 'SESSION_SET_XTERM_TITLE';
export const SESSION_SET_PROCESS_TITLE = 'SESSION_SET_PROCESS_TITLE';

View file

@ -0,0 +1,2 @@
export const CLOSE_TAB = 'CLOSE_TAB';
export const CHANGE_TAB = 'CHANGE_TAB';

8
app/lib/constants/ui.js Normal file
View file

@ -0,0 +1,8 @@
export const UI_FONT_SIZE_SET = 'UI_FONT_SIZE_SET';
export const UI_FONT_SIZE_INCR = 'UI_FONT_SIZE_INCR';
export const UI_FONT_SIZE_DECR = 'UI_FONT_SIZE_DECR';
export const UI_FONT_SIZE_RESET = 'UI_FONT_SIZE_RESET';
export const UI_MOVE_LEFT = 'UI_MOVE_LEFT';
export const UI_MOVE_RIGHT = 'UI_MOVE_RIGHT';
export const UI_MOVE_TO = 'UI_MOVE_TO';
export const UI_SHOW_PREFERENCES = 'UI_SHOW_PREFERENCES';

View file

@ -0,0 +1,2 @@
export const UPDATE_INSTALL = 'UPDATE_INSTALL';
export const UPDATE_AVAILABLE = 'UPDATE_AVAILABLE';

View file

@ -0,0 +1,48 @@
import Header from '../components/header';
import { closeTab, changeTab } from '../actions/header';
import { values } from '../utils/object';
import { createSelector } from 'reselect';
import { connect } from '../utils/plugins';
const isMac = /Mac/.test(navigator.userAgent);
const getSessions = (sessions) => sessions.sessions;
const getActiveUid = (sessions) => sessions.activeUid;
const getActivityMarkers = (sessions, ui) => ui.activityMarkers;
const getTabs = createSelector(
[getSessions, getActiveUid, getActivityMarkers],
(sessions, activeUid, activityMarkers) => values(sessions).map((s) => {
return {
uid: s.uid,
title: s.title,
isActive: s.uid === activeUid,
hasActivity: activityMarkers[s.uid]
};
})
);
const HeaderContainer = connect(
(state) => {
return {
// active is an index
isMac,
tabs: getTabs(state.sessions, state.ui),
activeMarkers: state.ui.activityMarkers,
borderColor: state.ui.borderColor,
backgroundColor: state.ui.backgroundColor
};
},
(dispatch) => {
return {
onCloseTab: (i) => {
dispatch(closeTab(i));
},
onChangeTab: (i) => {
dispatch(changeTab(i));
}
};
}
)(Header, 'Header');
export default HeaderContainer;

View file

@ -0,0 +1,137 @@
import React from 'react';
import HeaderContainer from './header';
import TermsContainer from './terms';
import NotificationsContainer from './notifications';
import Component from '../component';
import Mousetrap from 'mousetrap';
import overrideStyles from '../utils/override-style';
import { moveTo, moveLeft, moveRight } from '../actions/ui';
import { connect } from '../utils/plugins';
const isMac = /Mac/.test(navigator.userAgent);
class HyperTerm extends Component {
constructor (props) {
super(props);
this.focusActive = this.focusActive.bind(this);
document.body.style.backgroundColor = props.backgroundColor;
this.onTermsRef = this.onTermsRef.bind(this);
}
componentWillReceiveProps (next) {
if (this.props.backgroundColor !== next.backgroundColor) {
document.body.style.backgroundColor = next.backgroundColor;
}
}
focusActive () {
const term = this.terms.getActiveTerm();
if (term) term.focus();
}
attachKeyListeners () {
const { moveTo, moveLeft, moveRight } = this.props;
const term = this.terms.getActiveTerm();
if (!term) return;
const document = term.getTermDocument();
const keys = new Mousetrap(document);
keys.bind('command+1', moveTo.bind(this, 0));
keys.bind('command+2', moveTo.bind(this, 1));
keys.bind('command+3', moveTo.bind(this, 2));
keys.bind('command+4', moveTo.bind(this, 3));
keys.bind('command+5', moveTo.bind(this, 4));
keys.bind('command+6', moveTo.bind(this, 5));
keys.bind('command+7', moveTo.bind(this, 6));
keys.bind('command+8', moveTo.bind(this, 7));
keys.bind('command+9', moveTo.bind(this, 8));
keys.bind('command+shift+left', moveLeft);
keys.bind('command+shift+right', moveRight);
keys.bind('command+shift+[', moveLeft);
keys.bind('command+shift+]', moveRight);
keys.bind('command+alt+left', moveLeft);
keys.bind('command+alt+right', moveRight);
this.keys = keys;
}
onTermsRef (terms) {
this.terms = terms;
}
componentDidUpdate (prev) {
if (prev.activeSession !== this.props.activeSession) {
if (this.keys) this.keys.reset();
this.focusActive();
this.attachKeyListeners();
}
}
componentWillUnmount () {
if (this.keys) this.keys.reset();
document.body.style.backgroundColor = 'inherit';
}
template (css) {
const { isMac, customCSS, borderColor } = this.props;
return <div onClick={ this.focusActive }>
<div
ref={ overrideStyles({ borderColor }) }
className={ css('main', isMac && 'mainRounded') }>
<HeaderContainer />
<TermsContainer ref_={this.onTermsRef} />
</div>
<NotificationsContainer />
<style dangerouslySetInnerHTML={{ __html: customCSS }} />
{ this.props.customChildren }
</div>;
}
styles () {
return {
main: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
// can be overridden by inline style above
border: '1px solid #333'
},
mainRounded: {
borderRadius: '5px'
}
};
}
}
const HyperTermContainer = connect(
(state) => {
return {
isMac,
customCSS: state.ui.css,
borderColor: state.ui.borderColor,
activeSession: state.sessions.activeUid,
backgroundColor: state.ui.backgroundColor
};
},
(dispatch) => {
return {
moveTo: (i) => {
dispatch(moveTo(i));
},
moveLeft: () => {
dispatch(moveLeft());
},
moveRight: () => {
dispatch(moveRight());
}
};
},
null,
{ withRef: true }
)(HyperTerm, 'HyperTerm');
export default HyperTermContainer;

View file

@ -0,0 +1,61 @@
import Notifications from '../components/notifications';
import { installUpdate } from '../actions/updater';
import { connect } from '../utils/plugins';
import { dismissNotification } from '../actions/notifications';
const NotificationsContainer = connect(
(state) => {
const { ui } = state;
const { notifications } = ui;
const state_ = {};
if (notifications.font) {
const fontSize = ui.fontSize;
Object.assign(state_, {
fontShowing: true,
fontSize,
fontText: `${fontSize}px`
});
}
if (notifications.resize) {
const cols = ui.cols;
const rows = ui.rows;
Object.assign(state_, {
resizeShowing: true,
cols,
rows
});
}
if (notifications.updates) {
Object.assign(state_, {
updateShowing: true,
updateVersion: ui.updateVersion,
updateNote: ui.updateNotes.split('\n')[0]
});
}
return state_;
},
(dispatch) => {
return {
onDismissFont: () => {
dispatch(dismissNotification('font'));
},
onDismissResize: () => {
dispatch(dismissNotification('resize'));
},
onDismissUpdate: () => {
dispatch(dismissNotification('updates'));
},
onUpdateInstall: () => {
dispatch(installUpdate());
}
};
}
)(Notifications, 'Notifications');
export default NotificationsContainer;

View file

@ -0,0 +1,61 @@
import Terms from '../components/terms';
import { values } from '../utils/object';
import { connect } from '../utils/plugins';
import {
resizeSession,
sendSessionData,
exitSessionBrowser,
setSessionXtermTitle,
setActiveSession
} from '../actions/sessions';
const TermsContainer = connect(
(state) => {
const sessions = state.sessions.sessions;
return {
cols: state.ui.cols,
rows: state.ui.rows,
sessions: values(sessions),
activeSession: state.sessions.activeUid,
customCSS: state.ui.termCSS,
write: state.sessions.write,
fontSize: state.ui.fontSizeOverride
? state.ui.fontSizeOverride
: state.ui.fontSize,
fontFamily: state.ui.fontFamily,
padding: state.ui.padding,
cursorColor: state.ui.cursorColor,
borderColor: state.ui.borderColor,
colors: state.ui.colors,
foregroundColor: state.ui.foregroundColor,
backgroundColor: state.ui.backgroundColor
};
},
(dispatch) => {
return {
onData (uid, data) {
dispatch(sendSessionData(uid, data));
},
onTitle (uid, title) {
dispatch(setSessionXtermTitle(uid, title));
},
onResize (uid, cols, rows) {
dispatch(resizeSession(uid, cols, rows));
},
onURLAbort (uid) {
dispatch(exitSessionBrowser(uid));
},
onActive (uid) {
dispatch(setActiveSession(uid));
}
};
},
null,
{ withRef: true }
)(Terms, 'Terms');
export default TermsContainer;

View file

@ -43,5 +43,48 @@ hterm.Keyboard.prototype.onKeyPress_ = function (e) {
return oldKeyPress.call(this, e);
};
export { hterm };
// 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
hterm.Terminal.prototype.clearPreserveCursorRow = function () {
this.scrollbackRows_.length = 0;
this.scrollPort_.resetCache();
[this.primaryScreen_, this.alternateScreen_].forEach((screen) => {
const bottom = screen.getHeight();
if (bottom > 0) {
this.renumberRows_(0, bottom);
const x = screen.cursorPosition.column;
const y = screen.cursorPosition.row;
if (x === 0) {
// Empty screen, nothing to do.
return;
}
// here we move the row that the user was focused on
// to the top of the screen
this.moveRows_(y, 1, 0);
for (let i = 1; i < bottom; i++) {
screen.setCursorPosition(i, 0);
screen.clearCursorRow();
}
// we restore the cursor position
screen.setCursorPosition(0, x);
}
});
this.syncCursorPosition_();
this.scrollPort_.invalidate();
// this will avoid a bug where the `wipeContents`
// hterm API doens't send the scroll to the top
this.scrollPort_.redraw_();
};
export default hterm;
export { lib };

113
app/lib/index.js Normal file
View file

@ -0,0 +1,113 @@
import rpc from './rpc';
import React from 'react';
import { render } from 'react-dom';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { init } from './actions/index';
import effects from './utils/effects';
import * as config from './utils/config';
import rootReducer from './reducers/index';
import * as plugins from './utils/plugins';
import * as uiActions from './actions/ui';
import forceUpdate from 'react-deep-force-update';
import * as updaterActions from './actions/updater';
import * as sessionActions from './actions/sessions';
import { createStore, applyMiddleware } from 'redux';
import HyperTermContainer from './containers/hyperterm';
import { loadConfig, reloadConfig } from './actions/config';
import createLogger from 'redux-logger';
const logger = createLogger({ collapsed: true });
const store = createStore(
rootReducer,
applyMiddleware(
thunk,
logger,
plugins.middleware,
thunk,
effects
)
);
window.__defineGetter__('state', () => store.getState());
// initialize config
store.dispatch(loadConfig(config.getConfig()));
config.subscribe(() => {
store.dispatch(reloadConfig(config.getConfig()));
});
// initialize communication with main electron process
// and subscribe to all user intents for example from menues
rpc.on('ready', () => {
store.dispatch(init());
});
rpc.on('session add', ({ uid }) => {
store.dispatch(sessionActions.addSession(uid));
});
rpc.on('session data', ({ uid, data }) => {
store.dispatch(sessionActions.addSessionData(uid, data));
});
rpc.on('session title', ({ uid, title }) => {
store.dispatch(sessionActions.setSessionProcessTitle(uid, title));
});
rpc.on('session exit', ({ uid }) => {
store.dispatch(sessionActions.sessionExit(uid));
});
rpc.on('session add req', () => {
store.dispatch(sessionActions.requestSession());
});
rpc.on('session close req', () => {
store.dispatch(sessionActions.userExitActiveSession());
});
rpc.on('session clear req', () => {
store.dispatch(sessionActions.clearActiveSession());
});
rpc.on('reset fontSize req', () => {
store.dispatch(uiActions.resetFontSize());
});
rpc.on('increase fontSize req', () => {
store.dispatch(uiActions.increaseFontSize());
});
rpc.on('decrease fontSize req', () => {
store.dispatch(uiActions.resetFontSize());
});
rpc.on('move left req', () => {
store.dispatch(uiActions.moveLeft());
});
rpc.on('move right req', () => {
store.dispatch(uiActions.moveRight());
});
rpc.on('preferences', () => {
store.dispatch(uiActions.showPreferences());
});
rpc.on('update available', ({ releaseName, releaseNotes }) => {
store.dispatch(updaterActions.updateAvailable(releaseName, releaseNotes));
});
const app = render(
<Provider store={ store }>
<HyperTermContainer />
</Provider>,
document.getElementById('mount')
);
rpc.on('reload', () => {
plugins.reload();
forceUpdate(app);
});

View file

@ -0,0 +1,8 @@
import { combineReducers } from 'redux';
import ui from './ui';
import sessions from './sessions';
export default combineReducers({
ui,
sessions
});

View file

@ -0,0 +1,105 @@
import Immutable from 'seamless-immutable';
import { decorateSessionsReducer } from '../utils/plugins';
import {
SESSION_ADD,
SESSION_PTY_EXIT,
SESSION_USER_EXIT,
SESSION_PTY_DATA,
SESSION_SET_ACTIVE,
SESSION_CLEAR_ACTIVE,
SESSION_URL_SET,
SESSION_URL_UNSET,
SESSION_SET_XTERM_TITLE,
SESSION_SET_PROCESS_TITLE
} from '../constants/sessions';
const initialState = Immutable({
sessions: {},
write: null,
activeUid: null
});
function Session (obj) {
return Immutable({
uid: '',
title: '',
write: null,
url: null,
cleared: false
}).merge(obj);
}
function Write (obj) {
return Immutable({
uid: '',
data: ''
}).merge(obj);
}
const reducer = (state = initialState, action) => {
// prune the last write to the terminal
if (state.write) {
state = state.set('write', null);
}
switch (action.type) {
case SESSION_ADD:
return state.setIn(['sessions', action.uid], Session({ uid: action.uid }));
case SESSION_URL_SET:
return state.setIn(['sessions', action.uid, 'url'], action.url);
case SESSION_URL_UNSET:
return state.setIn(['sessions', action.uid, 'url'], null);
case SESSION_SET_ACTIVE:
return state.set('activeUid', action.uid);
case SESSION_CLEAR_ACTIVE:
return state.merge({
sessions: {
[state.activeUid]: {
cleared: true
}
}
}, { deep: true });
case SESSION_PTY_DATA:
return state.merge({
write: Write(action),
sessions: {
[action.uid]: {
cleared: false
}
}
}, { deep: true });
case SESSION_PTY_EXIT:
if (state.sessions[action.uid]) {
return deleteSession(state, action.uid);
} else {
console.log('ignore pty exit: session removed by user');
return state;
}
case SESSION_USER_EXIT:
return deleteSession(state, action.uid);
case SESSION_SET_XTERM_TITLE:
case SESSION_SET_PROCESS_TITLE:
return state.setIn(['sessions', action.uid, 'title'], action.title);
default:
return state;
}
};
export default decorateSessionsReducer(reducer);
function deleteSession (state, uid) {
return state.updateIn(['sessions'], (sessions) => {
const sessions_ = sessions.asMutable();
delete sessions_[uid];
return sessions_;
});
}

231
app/lib/reducers/ui.js Normal file
View file

@ -0,0 +1,231 @@
import Immutable from 'seamless-immutable';
import { decorateUIReducer } from '../utils/plugins';
import { CONFIG_LOAD, CONFIG_RELOAD } from '../constants/config';
import { UI_FONT_SIZE_SET, UI_FONT_SIZE_RESET } from '../constants/ui';
import { NOTIFICATION_DISMISS } from '../constants/notifications';
import {
SESSION_ADD,
SESSION_RESIZE,
SESSION_PTY_DATA,
SESSION_PTY_EXIT,
SESSION_SET_ACTIVE
} from '../constants/sessions';
import { UPDATE_AVAILABLE } from '../constants/updater';
// TODO: populate `config-default.js` from this :)
const initial = Immutable({
cols: null,
rows: null,
activeUid: null,
cursorColor: '#F81CE5',
borderColor: '#333',
fontSize: 12,
padding: '12px 14px',
fontFamily: 'Menlo, "DejaVu Sans Mono", "Lucida Console", monospace',
fontSizeOverride: null,
css: '',
termCSS: '',
openAt: {},
resizeAt: 0,
colors: [
'#000000',
'#ff0000',
'#33ff00',
'#ffff00',
'#0066ff',
'#cc00ff',
'#00ffff',
'#d0d0d0',
'#808080',
'#ff0000',
'#33ff00',
'#ffff00',
'#0066ff',
'#cc00ff',
'#00ffff',
'#ffffff'
],
activityMarkers: {},
notifications: {
font: false,
resize: false,
updates: false
},
foregroundColor: '#fff',
backgroundColor: '#000',
updateVersion: null,
updateNotes: null
});
const reducer = (state = initial, action) => {
let state_ = state;
switch (action.type) {
case CONFIG_LOAD:
case CONFIG_RELOAD:
const { config } = action;
state_ = state
// we unset the user font size override if the
// font size changed from the config
.merge((() => {
const ret = {};
if (state.fontSizeOverride && config.fontSize !== state.fontSize) {
ret.fontSizeOverride = null;
}
if (null != config.fontSize) {
ret.fontSize = config.fontSize;
}
if (null != config.fontFamily) {
ret.fontFamily = config.fontFamily;
}
if (null != config.cursorColor) {
ret.cursorColor = config.cursorColor;
}
if (null != config.borderColor) {
ret.borderColor = config.borderColor;
}
if (null != config.padding) {
ret.padding = config.padding;
}
if (null != config.foregroundColor) {
ret.foregroundColor = config.foregroundColor;
}
if (null != config.backgroundColor) {
ret.backgroundColor = config.backgroundColor;
}
if (null != config.css) {
ret.css = config.css;
}
if (null != config.termCSS) {
ret.termCSS = config.termCSS;
}
if (null != config.colors) {
if (state.colors.toString() !== config.colors.toString()) {
ret.colors = config.colors;
}
}
return ret;
})());
break;
case SESSION_ADD:
state_ = state.merge({
openAt: {
[action.uid]: Date.now()
}
}, { deep: true });
break;
case SESSION_RESIZE:
state_ = state.merge({
rows: action.rows,
cols: action.cols,
resizeAt: Date.now()
});
break;
case SESSION_PTY_EXIT:
state_ = state
.updateIn(['openAt'], (times) => {
const times_ = times.asMutable();
delete times_[action.uid];
return times_;
})
.updateIn(['activityMarkers'], (markers) => {
const markers_ = markers.asMutable();
delete markers_[action.uid];
return markers_;
});
break;
case SESSION_SET_ACTIVE:
state_ = state.merge({
activeUid: action.uid,
activityMarkers: {
[action.uid]: false
}
}, { deep: true });
break;
case SESSION_PTY_DATA:
// ignore activity markers for current tab
if (action.uid === state.activeUid) break;
// current time for comparisons
let now = Date.now();
// if first data events after open, ignore
if (now - state.openAt[action.uid] < 1000) break;
// we ignore activity markers that are within
// proximity of a resize event, since we
// expect to get data packets from the resize
// of the ptys as a result
if (!state.resizeAt || now - state.resizeAt > 1000) {
state_ = state.merge({
activityMarkers: {
[action.uid]: true
}
}, { deep: true });
}
break;
case UI_FONT_SIZE_SET:
state_ = state.set('fontSizeOverride', state.value);
break;
case UI_FONT_SIZE_RESET:
state_ = state.set('fontSizeOverride', null);
break;
case NOTIFICATION_DISMISS:
state_ = state.merge({
notifications: {
[action.id]: false
}
}, { deep: true });
break;
case UPDATE_AVAILABLE:
state_ = state.merge({
updateVersion: action.version,
updateNotes: action.notes || ''
});
break;
}
// we check that if any of the font size values changed
// we show a notification
if (CONFIG_LOAD !== action.type) {
if (state_.fontSize !== state.fontSize ||
state_.fontSizeOverride !== state.fontSizeOverride) {
state_ = state_.merge({ notifications: { font: true } }, { deep: true });
}
}
if (null != state.cols && null != state.rows &&
(state.rows !== state_.rows ||
state.cols !== state_.cols)) {
state_ = state_.merge({ notifications: { resize: true } }, { deep: true });
}
if (state.updateVersion !== state_.updateVersion) {
state_ = state_.merge({ notifications: { updates: true } }, { deep: true });
}
return state_;
};
export default decorateUIReducer(reducer);

3
app/lib/rpc.js Normal file
View file

@ -0,0 +1,3 @@
import RPC from './utils/rpc';
const rpc = new RPC();
export default rpc;

3
app/lib/utils/array.js Normal file
View file

@ -0,0 +1,3 @@
export function last (arr) {
return arr[arr.length - 1];
}

13
app/lib/utils/config.js Normal file
View file

@ -0,0 +1,13 @@
import { ipcRenderer, remote } from 'electron';
const config = remote.require('./config');
export function getConfig () {
return config.getConfig();
}
export function subscribe (fn) {
ipcRenderer.on('config change', fn);
return () => {
ipcRenderer.removeListener('config change', fn);
};
}

15
app/lib/utils/effects.js vendored Normal file
View file

@ -0,0 +1,15 @@
// simple redux middleware that executes
// the `effect` field if provided in an action
// since this is preceded by the `plugins`
// middleware, it allows authors to interrumpt,
// defer or add to existing side effects at will
// as the result of an action being triggered
export default (store) => (next) => (action) => {
const ret = next(action);
if (action.effect) {
action.effect();
delete action.effect;
}
return ret;
};

View file

@ -1,5 +1,6 @@
/* global Notification */
/* eslint no-new:0 */
export default function notify (title, body) {
console.log(`[Notification] ${title}: ${body}`);
new Notification(title, { body });
}

17
app/lib/utils/object.js Normal file
View file

@ -0,0 +1,17 @@
import vals from 'object-values';
const valsCache = new WeakMap();
export function values (imm) {
if (!valsCache.has(imm)) {
valsCache.set(imm, vals(imm));
}
return valsCache.get(imm);
}
const keysCache = new WeakMap();
export function keys (imm) {
if (!keysCache.has(imm)) {
keysCache.set(imm, Object.keys(imm));
}
return keysCache.get(imm);
}

View file

@ -0,0 +1,31 @@
// hack to configure important style attributes
// to work around this madness:
// https://github.com/Khan/aphrodite/pull/41
export default function overrideStyle (obj) {
if (obj) {
return function (ref) {
if (ref) {
for (const key in obj) {
if (null != obj[key]) {
const val = 'number' === typeof obj[key]
? `${obj[key]}px`
: String(obj[key]);
const prop = toProp(key);
ref.style.setProperty(prop, val, 'important');
}
}
}
};
} else {
return null;
}
}
// converts camelCase to camel-case
function toProp (key) {
return key.replace(
/[a-z]([A-Z])/,
(a, b) => a.substr(0, a.length - 1) + '-' + b.toLowerCase()
);
}

382
app/lib/utils/plugins.js Normal file
View file

@ -0,0 +1,382 @@
import { ipcRenderer, remote } from 'electron';
import { connect as reduxConnect } from 'react-redux';
// we expose these two deps to component decorators
import React from 'react';
import Notification from '../components/notification';
import notify from './notify';
// remote interface to `../plugins`
let plugins = remote.require('./plugins');
// `require`d modules
let modules;
// cache of decorated components
let decorated = {};
// varios caches extracted of the plugin methods
let connectors;
let middlewares;
let uiReducers;
let sessionsReducers;
let tabPropsDecorators;
let tabsPropsDecorators;
let termPropsDecorators;
// the fs locations where usr plugins are stored
const { path, localPath } = plugins.getBasePaths();
const clearModulesCache = () => {
// clear require cache
for (const entry in window.require.cache) {
if (entry.indexOf(path) === 0 || entry.indexOf(localPath) === 0) {
// `require` is webpacks', `window.require`, electron's
delete window.require.cache[entry];
}
}
};
const getPluginName = (path) => window.require('path').basename(path);
const loadModules = () => {
console.log('(re)loading renderer plugins');
const paths = plugins.getPaths();
// initialize cache that we populate with extension methods
connectors = {
Terms: { state: [], dispatch: [] },
Header: { state: [], dispatch: [] },
HyperTerm: { state: [], dispatch: [] },
Notifications: { state: [], dispatch: [] }
};
uiReducers = [];
middlewares = [];
sessionsReducers = [];
tabPropsDecorators = [];
tabsPropsDecorators = [];
termPropsDecorators = [];
modules = paths.plugins.concat(paths.localPlugins)
.map((path) => {
let mod;
const pluginName = getPluginName(path);
// window.require allows us to ensure this doens't get
// in the way of our build
try {
mod = window.require(path);
} catch (err) {
console.error(err.stack);
notify('Plugin load error', `"${pluginName}" failed to load in the renderer process. Check Developer Tools for details.`);
return;
}
for (const i in mod) {
mod[i]._pluginName = pluginName;
}
if (mod.middleware) {
middlewares.push(mod.middleware);
}
if (mod.reduceUI) {
uiReducers.push(mod.reduceUI);
}
if (mod.reduceSessions) {
sessionsReducers.push(mod.reduceSessions);
}
if (mod.mapTermsState) {
connectors.Terms.state.push(mod.mapTermsState);
}
if (mod.mapTermsDispatch) {
connectors.Terms.dispatch.push(mod.mapTermsState);
}
if (mod.mapHeaderState) {
connectors.Header.state.push(mod.mapHeaderState);
}
if (mod.mapHeaderDispatch) {
connectors.Header.dispatch.push(mod.mapHeaderDispatch);
}
if (mod.mapHyperTermState) {
connectors.HyperTerm.state.push(mod.mapHyperTermState);
}
if (mod.mapHyperTermDispatch) {
connectors.HyperTerm.dispatch.push(mod.mapHyperTermDispatch);
}
if (mod.mapNotificationsState) {
connectors.Notifications.state.push(mod.mapNotificationsState);
}
if (mod.mapNotificationsDispatch) {
connectors.Notifications.dispatch.push(mod.mapNotificationsDispatch);
}
if (mod.getTermProps) {
termPropsDecorators.push(mod.getTermProps);
}
if (mod.getTabProps) {
tabPropsDecorators.push(mod.getTabProps);
}
if (mod.getTabsProps) {
tabsPropsDecorators.push(mod.getTabsProps);
}
return mod;
})
.filter((mod) => !!mod);
};
// load modules for initial decoration
loadModules();
export function reload () {
clearModulesCache();
loadModules();
// trigger re-decoration when components
// get re-rendered
decorated = {};
}
// we want to refresh our modules cache every time
// plugins reload.
// the re-painting happens by the top-level `Config` component
// that reacts to configuration changes and plugin changes
ipcRenderer.on('plugins change', function (ev) {
notify(
'Plugins Updated',
'Restart or choose "Plugins" > "Reload Now"'
);
});
export function getTermProps (uid, parentProps, props) {
let props_;
termPropsDecorators.forEach((fn) => {
let ret_;
if (!props_) props_ = Object.assign({}, props);
try {
ret_ = fn(uid, parentProps, props_);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`getTermProps\`. Check Developer Tools for details.`);
return;
}
if (!ret_ || 'object' !== typeof ret_) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`getTermProps\` (object expected).`);
return;
}
props = ret_;
});
return props_ || props;
}
export function getTabsProps (parentProps, props) {
let props_;
tabsPropsDecorators.forEach((fn) => {
let ret_;
if (!props_) props_ = Object.assign({}, props);
try {
ret_ = fn(parentProps, props_);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`getTabsProps\`. Check Developer Tools for details.`);
return;
}
if (!ret_ || 'object' !== typeof ret_) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`getTabsProps\` (object expected).`);
return;
}
props_ = ret_;
});
return props_ || props;
}
export function getTabProps (tab, parentProps, props) {
let props_;
tabPropsDecorators.forEach((fn) => {
let ret_;
if (!props_) props_ = Object.assign({}, props);
try {
ret_ = fn(tab, parentProps, props_);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`getTabProps\`. Check Developer Tools for details.`);
return;
}
if (!ret_ || 'object' !== typeof ret_) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`getTabProps\` (object expected).`);
return;
}
props_ = ret_;
});
return props_ || props;
}
// connects + decorates a class
// plugins can override mapToState, dispatchToProps
// and the class gets deorated (proxied)
export function connect (stateFn, dispatchFn, c, d = {}) {
return function (Class, name) {
return reduxConnect(
function (state) {
let ret = stateFn(state);
connectors[name].state.forEach((fn) => {
let ret_;
try {
ret_ = fn(state, ret);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`map${name}State\`. Check Developer Tools for details.`);
return;
}
if (!ret_ || 'object' !== typeof ret_) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`map${name}State\` (object expected).`);
return;
}
ret = ret_;
});
return ret;
},
dispatchFn,
c,
d
)(decorate(Class, name));
};
}
export function decorateUIReducer (fn) {
return (state, action) => {
let state_ = fn(state, action);
uiReducers.forEach((pluginReducer) => {
let state__;
try {
state__ = pluginReducer(state_, action);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`reduceUI\`. Check Developer Tools for details.`);
return;
}
if (!state__ || 'object' !== typeof state__) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`reduceUI\`.`);
return;
}
state_ = state__;
});
return state_;
};
}
export function decorateSessionsReducer (fn) {
return (state, action) => {
let state_ = fn(state, action);
sessionsReducers.forEach((pluginReducer) => {
let state__;
try {
state__ = pluginReducer(state_, action);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`reduceSessions\`. Check Developer Tools for details.`);
return;
}
if (!state__ || 'object' !== typeof state__) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`reduceSessions\`.`);
return;
}
state_ = state__;
});
return state_;
};
}
// redux middleware generator
export const middleware = (store) => (next) => (action) => {
const nextMiddleware = remaining => action => remaining.length
? remaining[0](store)(nextMiddleware(remaining.slice(1)))(action)
: next(action);
nextMiddleware(middlewares)(action);
};
function getDecorated (parent, name) {
if (!decorated[name]) {
let class_ = parent;
modules.forEach((mod) => {
const method = 'decorate' + name;
const fn = mod[method];
if (fn) {
let class__;
try {
class__ = fn(class_, { React, Notification, notify });
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`${method}\`. Check Developer Tools for details`);
return;
}
if (!class__ || 'function' !== typeof class__.prototype.render) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`${method}\`. No \`render\` method found. Please return a \`React.Component\`.`);
return;
}
class_ = class__;
}
});
decorated[name] = class_;
}
return decorated[name];
}
// for each component, we return the `react-proxy`d component
export function decorate (Component, name) {
return class extends React.Component {
render () {
const Sub = getDecorated(Component, name);
return <Sub {...this.props} />;
}
};
}

View file

@ -0,0 +1,40 @@
import * as regex from './url-regex';
export default function isUrlCommand (data) {
let match = data.match(regex.bash);
let url;
if (match) {
url = match[5];
} else {
match = data.match(regex.zsh);
if (match) {
url = match[7];
} else {
match = data.match(regex.fish);
if (match) {
url = match[4];
}
}
}
if (url) {
// extract the domain portion from the url
const domain = url.split('/')[0];
if (regex.domain.test(domain)) {
return toURL(url);
}
}
}
function toURL (domain) {
if (/^https?:\/\//.test(domain)) {
return domain;
}
if ('//' === domain.substr(0, 2)) {
return domain;
}
return 'http://' + domain;
}

View file

@ -1,36 +1,40 @@
{
"name": "hyperterm-web",
"version": "0.0.1",
"description": "",
"description": "Web app that runs in the renderer process",
"license": "MIT",
"repository": "zeit/hyperterm",
"dependencies": {
"classnames": "2.2.5",
"aphrodite": "0.4.1",
"hterm-umdjs": "1.1.2",
"json-loader": "0.5.4",
"mousetrap": "1.6.0",
"ms": "0.7.1",
"object-values": "1.0.0",
"react": "15.2.1",
"react-addons-shallow-compare": "15.2.1",
"react-addons-pure-render-mixin": "15.2.1",
"react-deep-force-update": "2.0.1",
"react-dom": "15.2.1",
"react-proxy": "1.1.8",
"semver-compare": "1.0.0"
"react-redux": "4.4.5",
"redux": "3.5.2",
"redux-logger": "2.6.1",
"redux-thunk": "2.1.0",
"reselect": "2.5.3",
"seamless-immutable": "6.1.1"
},
"devDependencies": {
"babel-cli": "6.10.1",
"babel-core": "6.10.4",
"babel-eslint": "6.1.1",
"babel-loader": "6.2.4",
"babel-plugin-transform-es2015-classes": "6.9.0",
"babel-plugin-transform-es2015-modules-commonjs": "6.10.3",
"babel-plugin-transform-react-jsx": "6.8.0",
"babel-preset-es2015": "6.9.0",
"css-loader": "0.23.1",
"babel-preset-es2015-native-modules": "6.6.0",
"babel-preset-react": "6.11.1",
"eslint": "3.0.1",
"eslint-config-standard": "5.3.1",
"eslint-plugin-promise": "1.3.2",
"eslint-plugin-react": "5.2.2",
"eslint-plugin-standard": "1.3.2",
"style-loader": "0.13.1",
"webpack": "1.13.1"
"webpack": "2.1.0-beta.15"
},
"eslintConfig": {
"extends": "standard",
@ -62,10 +66,9 @@
}
},
"babel": {
"plugins": [
"transform-es2015-classes",
"transform-es2015-modules-commonjs",
"transform-react-jsx"
"presets": [
"es2015-native-modules",
"react"
]
},
"scripts": {

View file

@ -1,88 +0,0 @@
import { ipcRenderer, remote } from 'electron';
import notify from './notify';
import { createProxy } from 'react-proxy';
// remote interface to `../plugins`
let plugins = remote.require('./plugins');
// `require`d modules
let modules;
// the fs locations where usr plugins are stored
const { path, localPath } = plugins.getBasePaths();
// where we store the decorated components
let proxies = {};
const clearCache = () => {
// clear require cache
for (const entry in window.require.cache) {
if (entry.indexOf(path) === 0 || entry.indexOf(localPath) === 0) {
// `require` is webpacks', `window.require`, electron's
delete window.require.cache[entry];
}
}
};
const loadModules = () => {
console.log('(re)loading renderer plugins');
const paths = plugins.getPaths();
modules = paths.plugins.concat(paths.localPlugins).map((path) => {
// window.require allows us to ensure this doens't get
// in the way of our build
try {
return window.require(path);
} catch (err) {
const name = remote.require('path').basename(path);
console.error(err.stack);
notify('Plugin load error', `"${name}" failed to load in the renderer process. Check Developer Tools for details.`);
}
});
};
const updateProxy = (name) => {
const [Component, proxy] = proxies[name];
let decorated = Component;
modules.forEach((mod) => {
const decorator = mod[`decorate${name}`];
if (decorator) {
decorated = decorator(Component);
}
});
if (decorated !== Component) {
proxy.update(decorated);
}
};
const updateProxies = () => {
for (const name in proxies) {
updateProxy(name);
}
};
// load modules for initial decoration
loadModules();
// we want to refresh our modules cache every time
// plugins reload.
// the re-painting happens by the top-level `Config` component
// that reacts to configuration changes and plugin changes
ipcRenderer.on('plugins change', () => {
clearCache();
loadModules();
updateProxies();
});
// for each component, we return the `react-proxy`d component
export default function decorate (Component, props = null) {
const name = Component.name;
if (!proxies[name]) {
const proxy = createProxy(Component);
proxies[name] = [Component, proxy];
updateProxy(name);
}
const [, proxy] = proxies[name];
return proxy.get();
}

View file

@ -1,52 +0,0 @@
import React from 'react';
import classes from 'classnames';
export default class Tabs extends React.Component {
render () {
const {
data = [],
borderColor,
active,
activeMarkers = [],
onChange,
onClose
} = this.props;
return <nav style={{ WebkitAppRegion: 'drag' }}>{
data.length
? 1 === data.length
? <div className='single'>{ data[0] }</div>
: <ul style={{ borderColor }} 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 })}>
<span
style={{ borderColor: isActive ? borderColor : null }}
onClick={ onChange ? onClick.bind(null, i, onChange, active) : null }>
{ tab }
</span>
<i onClick={ onClose ? onClose.bind(null, i) : null }>
<svg className='icon'>
<use xlinkHref='assets/icons.svg#close'></use>
</svg>
</i>
</li>;
})
}
</ul>
: null
}</nav>;
}
}
function onClick (i, onChange, active) {
if (i !== active) {
onChange(i);
}
}

View file

@ -6,7 +6,7 @@ const isProd = nodeEnv === 'production';
module.exports = {
devtool: isProd ? 'hidden-source-map' : 'cheap-eval-source-map',
entry: './index.js',
entry: './lib/index.js',
output: {
path: path.join(__dirname, './dist'),
filename: 'bundle.js'
@ -23,16 +23,24 @@ module.exports = {
{
test: /\.json/,
loader: 'json-loader'
},
{
test: /\.css$/,
loader: 'style-loader!css-loader'
}
]
},
plugins: [
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.ExternalsPlugin('commonjs', ['electron']),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
},
output: {
comments: false
},
sourceMap: false
}),
new webpack.LoaderOptionsPlugin({
minimize: true,
debug: false
}),
new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify(nodeEnv)

View file

@ -1,6 +1,6 @@
module.exports = {
config: {
// default font size for all tabs
// default font size in pixels for all tabs
fontSize: 12,
// font family with optional fallbacks
@ -9,17 +9,23 @@ module.exports = {
// terminal cursor background color (hex)
cursorColor: '#F81CE5',
// terminal background color (hex)
backgroundColor: '#000000',
// color of the text
foregroundColor: '#fff',
// terminal background color
backgroundColor: '#000',
// border color (window, tabs)
borderColor: '#333',
// custom css to embed in the main window
css: [''],
css: '',
// custom css to embed inside each terminal
termCSS: [''],
// custom padding (css format, i.e.: `top right bottom left`)
termCSS: '',
// custom padding
padding: '12px 14px',
// some color overrides. see http://bit.ly/29k1iU2 for
// the full list

View file

@ -6,6 +6,7 @@ const genUid = require('uid2');
const { resolve } = require('path');
const isDev = require('electron-is-dev');
const AutoUpdater = require('./auto-updater');
const toHex = require('convert-css-color-name-to-hex');
// set up config
const config = require('./config');
@ -44,7 +45,7 @@ app.on('ready', () => {
height: 380,
titleBarStyle: 'hidden-inset',
title: 'HyperTerm',
backgroundColor: config.getConfig().backgroundColor || '#000',
backgroundColor: toHex(config.getConfig().backgroundColor || '#000'),
transparent: true,
// we only want to show when the prompt
// is ready for user input
@ -75,29 +76,42 @@ app.on('ready', () => {
rpc.on('new', ({ rows = 40, cols = 100 }) => {
initSession({ rows, cols }, (uid, session) => {
sessions.set(uid, session);
rpc.emit('new session', { uid });
rpc.emit('session add', { uid });
session.on('data', (data) => {
rpc.emit('data', { uid, data });
rpc.emit('session data', { uid, data });
});
session.on('title', (title) => {
rpc.emit('title', { uid, title });
rpc.emit('session title', { uid, title });
});
session.on('exit', () => {
rpc.emit('exit', { uid });
rpc.emit('session exit', { uid });
sessions.delete(uid);
});
});
});
// TODO: this goes away when we are able to poll
// for the title ourseleves, instead of relying
// on Session and focus/blur to subscribe
rpc.on('focus', ({ uid }) => {
sessions.get(uid).focus();
const session = sessions.get(uid);
if (session) {
session.focus();
} else {
console.log('session not found by', uid);
}
});
rpc.on('blur', ({ uid }) => {
sessions.get(uid).blur();
const session = sessions.get(uid);
if (session) {
session.blur();
} else {
console.log('session not found by', uid);
}
});
rpc.on('exit', ({ uid }) => {
@ -154,9 +168,15 @@ app.on('ready', () => {
// load plugins
load();
const pluginsUnsubscribe = plugins.subscribe(() => {
load();
win.webContents.send('plugins change');
const pluginsUnsubscribe = plugins.subscribe((err, { force }) => {
if (!err) {
load();
if (force) {
win.webContents.send('plugins reload');
} else {
win.webContents.send('plugins change');
}
}
});
// the window can be closed by the browser process itself

41
menu.js
View file

@ -66,7 +66,7 @@ module.exports = function createMenu ({ createWindow, updatePlugins }) {
accelerator: 'CmdOrCtrl+T',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('new tab');
focusedWindow.rpc.emit('session add req');
} else {
createWindow();
}
@ -77,7 +77,7 @@ module.exports = function createMenu ({ createWindow, updatePlugins }) {
accelerator: 'CmdOrCtrl+W',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('close tab');
focusedWindow.rpc.emit('session close req');
}
}
}
@ -95,7 +95,7 @@ module.exports = function createMenu ({ createWindow, updatePlugins }) {
accelerator: 'CmdOrCtrl+K',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('clear');
focusedWindow.rpc.emit('session clear req');
}
}
}
@ -108,7 +108,18 @@ module.exports = function createMenu ({ createWindow, updatePlugins }) {
label: 'Reload',
accelerator: 'CmdOrCtrl+R',
click (item, focusedWindow) {
if (focusedWindow) focusedWindow.reload();
if (focusedWindow) {
focusedWindow.rpc.emit('reload');
}
}
},
{
label: 'Full Reload',
accelerator: 'CmdOrCtrl+Shift+R',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.reload();
}
}
},
{
@ -128,7 +139,7 @@ module.exports = function createMenu ({ createWindow, updatePlugins }) {
accelerator: 'CmdOrCtrl+0',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('reset font size');
focusedWindow.rpc.emit('reset fontSize req');
}
}
},
@ -137,7 +148,7 @@ module.exports = function createMenu ({ createWindow, updatePlugins }) {
accelerator: 'CmdOrCtrl+plus',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('increase font size');
focusedWindow.rpc.emit('increase fontSize req');
}
}
},
@ -146,24 +157,12 @@ module.exports = function createMenu ({ createWindow, updatePlugins }) {
accelerator: 'CmdOrCtrl+-',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('decrease font size');
focusedWindow.rpc.emit('decrease fontSize req');
}
}
}
]
},
{
label: 'Tools',
submenu: [
{
label: 'Update plugins',
accelerator: 'CmdOrCtrl+U',
click (item, focusedWindow) {
updatePlugins();
}
}
]
},
{
label: 'Window',
submenu: [
@ -181,7 +180,7 @@ module.exports = function createMenu ({ createWindow, updatePlugins }) {
accelerator: 'CmdOrCtrl+Left',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('move left');
focusedWindow.rpc.emit('move left req');
}
}
},
@ -190,7 +189,7 @@ module.exports = function createMenu ({ createWindow, updatePlugins }) {
accelerator: 'CmdOrCtrl+Right',
click (item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('move right');
focusedWindow.rpc.emit('move right req');
}
}
},

View file

@ -30,6 +30,7 @@ app.on('ready', () => {
});
function notify (title, body) {
console.log(`[Notification] ${title}: ${body}`);
if (win) {
win.webContents.send('notification', { title, body });
} else {

View file

@ -7,6 +7,7 @@
"description": "HTML/JS/CSS Terminal",
"dependencies": {
"child_pty": "3.0.1",
"convert-css-color-name-to-hex": "0.1.1",
"default-shell": "1.0.1",
"electron-config": "0.2.0",
"electron-is-dev": "0.1.1",

View file

@ -75,16 +75,16 @@ function updatePlugins ({ force = false } = {}) {
// cache modules
modules = requirePlugins();
// notify watchers
watchers.forEach((fn) => fn(err));
const loaded = modules.length;
const total = paths.plugins.length + paths.localPlugins.length;
const pluginVersions = JSON.stringify(getPluginVersions());
if (force || (cache.get('plugin-versions') !== pluginVersions && loaded === total)) {
notify('HyperTerm plugins updated!');
}
const changed = cache.get('plugin-versions') !== pluginVersions && loaded === total;
cache.set('plugin-versions', pluginVersions);
// notify watchers
if (force || changed) {
watchers.forEach((fn) => fn(err, { force }));
}
}
});
}
@ -221,9 +221,12 @@ function requirePlugins () {
mod = require(path);
if (!mod || (!mod.onApp && !mod.onWindow && !mod.onUnload &&
!mod.middleware &&
!mod.decorateConfig && !mod.decorateMenu &&
!mod.decorateTerm && !mod.decorateHyperTerm &&
!mod.decorateTabs && !mod.decorateConfig)) {
!mod.decorateTab && !mod.decorateNotification &&
!mod.decorateNotifications && !mod.decorateTabs &&
!mod.decorateConfig)) {
notify('Plugin error!', `Plugin "${basename(path)}" does not expose any ` +
'HyperTerm extension API methods');
return;