Split Panes (#693)

* npm: add .npmrc with save-exact=true

* split panes: create initial implementation

This allows users to split their Hyperterm terms into
multiple nested splits, both vertical and horizontal.

Fixes #56

* split panes: suport closing tabs and individual panes

* split panes: ensure new splits are placed at the correct index

New split panes should be placed after the currently active
pane, not at the end like they were previously.

* split panes: add explicit dependency to uuid

* split panes: implement split pane cycling

This adds menu buttons for moving back and forward between
open split panes in the currect terminal tab.
Doesn't add a hotkey yet, needs some bikeshedding.

* split panes: move activeSessionUid to its own object

It made little sense to have so many objects with `activeSessionUid`
set to `null` when it only mattered on the top level.
Now it's an object mapping term-group `uid` to `sessionUid` instead.

* split panes: make sure closing the last split pane exits the app

* split panes: fix a crash after closing specific panes

Sometimes the terminal would crash when a specific
split pane was closed, because the `activeSessions`
mapping wasn't updated correctly.

* split panes: fix a bug that caused initial session sizing to be wrong

* fix all our focus / blur issues in one fell swoop :O (famous last words)

* get rid of react warning

* hterm: make sure not to lose focus when VT listens on clicks

* term: restore onactive callback

* add missing `return` to override (just in case)

* split pane: new split pane implementation

* goodbye react-split-pane

* added term group resizing action and reducer

* terms: supply border color so that we can use it for splits

* term-group: add resizing hook

* term-groups: add resizing constant

* remove split pane css side-effect

* split panes: pass existing hterm instances to Term

* split panes: add keybindings for split pane cycling

* split panes: remove unused action

* split panes: remove unused styling

* split-pane: remove `console.log`

* split-pane: remove `console.log`

* split panes: rebalance sizes on insert/removal

* split panes: pass existing hterm instances to Term

* split panes: add keybindings for split pane cycling

* split panes: remove unused action

* split panes: remove unused styling

* split panes: rebalance sizes on insert/removal

* split panes: set a minimum size for resizing

* split-pane: fix vertical splits

* css :|

* package: bump electron

* split panes: attach onFocus listener to webviews

* 1.4.1 and 1.4.2 are broken. they have the following regression:
- open google.com on the main window
- open a new tab
- come back to previous tab. webview is gone :|

* split panes: handle PTY exits

* split panes: add linux friendly keybindings
This commit is contained in:
Martin Ek 2016-10-03 22:00:50 -04:00 committed by Guillermo Rauch
parent ee172c3164
commit a7595c1a45
29 changed files with 1171 additions and 211 deletions

1
.npmrc Normal file
View file

@ -0,0 +1 @@
save-exact=true

1
app/.npmrc Normal file
View file

@ -0,0 +1 @@
save-exact=true

View file

@ -155,7 +155,7 @@ app.on('ready', () => installDevExtensions(isDev).then(() => {
// If no callback is passed to createWindow,
// a new session will be created by default.
if (!fn) {
fn = win => win.rpc.emit('session add req');
fn = win => win.rpc.emit('termgroup add req');
}
// app.windowCallback is the createWindow callback
@ -173,14 +173,17 @@ app.on('ready', () => installDevExtensions(isDev).then(() => {
}
});
rpc.on('new', ({rows = 40, cols = 100, cwd = process.env.HOME}) => {
rpc.on('new', ({rows = 40, cols = 100, cwd = process.env.HOME, splitDirection}) => {
const shell = cfg.shell;
const shellArgs = cfg.shellArgs && Array.from(cfg.shellArgs);
initSession({rows, cols, cwd, shell, shellArgs}, (uid, session) => {
sessions.set(uid, session);
rpc.emit('session add', {
rows,
cols,
uid,
splitDirection,
shell: session.shell,
pid: session.pty.pid
});
@ -242,10 +245,9 @@ app.on('ready', () => installDevExtensions(isDev).then(() => {
win.maximize();
});
rpc.on('resize', ({cols, rows}) => {
sessions.forEach(session => {
session.resize({cols, rows});
});
rpc.on('resize', ({uid, cols, rows}) => {
const session = sessions.get(uid);
session.resize({cols, rows});
});
rpc.on('data', ({uid, data}) => {

View file

@ -73,7 +73,7 @@ module.exports = function createMenu({createWindow, updatePlugins}) {
accelerator: 'CmdOrCtrl+T',
click(item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('session add req');
focusedWindow.rpc.emit('termgroup add req');
} else {
createWindow();
}
@ -82,12 +82,33 @@ module.exports = function createMenu({createWindow, updatePlugins}) {
{
type: 'separator'
},
{
label: 'Split Vertically',
accelerator: 'Ctrl+Shift+E',
click(item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('split request vertical');
}
}
},
{
label: 'Split Horizontally',
accelerator: 'Ctrl+Shift+O',
click(item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('split request horizontal');
}
}
},
{
type: 'separator'
},
{
label: 'Close',
accelerator: 'CmdOrCtrl+W',
click(item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('session close req');
focusedWindow.rpc.emit('termgroup close req');
}
}
},
@ -264,6 +285,27 @@ module.exports = function createMenu({createWindow, updatePlugins}) {
{
type: 'separator'
},
{
label: 'Select Next Pane',
accelerator: 'Ctrl+Alt+Tab',
click(item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('next pane req');
}
}
},
{
label: 'Select Previous Pane',
accelerator: 'Ctrl+Shift+Alt+Tab',
click(item, focusedWindow) {
if (focusedWindow) {
focusedWindow.rpc.emit('prev pane req');
}
}
},
{
type: 'separator'
},
{
role: 'front'
},

View file

@ -1,8 +1,7 @@
import {CLOSE_TAB, CHANGE_TAB} from '../constants/tabs';
import {UI_WINDOW_MAXIMIZE, UI_WINDOW_UNMAXIMIZE} from '../constants/ui';
import rpc from '../rpc';
import {userExitSession, setActiveSession} from './sessions';
import {userExitTermGroup, setActiveGroup} from './term-groups';
export function closeTab(uid) {
return dispatch => {
@ -10,7 +9,7 @@ export function closeTab(uid) {
type: CLOSE_TAB,
uid,
effect() {
dispatch(userExitSession(uid));
dispatch(userExitTermGroup(uid));
}
});
};
@ -22,7 +21,7 @@ export function changeTab(uid) {
type: CHANGE_TAB,
uid,
effect() {
dispatch(setActiveSession(uid));
dispatch(setActiveGroup(uid));
}
});
};

View file

@ -1,6 +1,7 @@
import rpc from '../rpc';
import getURL from '../utils/url-command';
import {keys} from '../utils/object';
import {findBySession} from '../utils/term-groups';
import {
SESSION_ADD,
SESSION_RESIZE,
@ -9,7 +10,6 @@ import {
SESSION_PTY_DATA,
SESSION_PTY_EXIT,
SESSION_USER_EXIT,
SESSION_USER_EXIT_ACTIVE,
SESSION_SET_ACTIVE,
SESSION_CLEAR_ACTIVE,
SESSION_USER_DATA,
@ -19,13 +19,18 @@ import {
SESSION_SET_PROCESS_TITLE
} from '../constants/sessions';
export function addSession(uid, shell, pid) {
return dispatch => {
export function addSession({uid, shell, pid, cols, rows, splitDirection}) {
return (dispatch, getState) => {
const {sessions} = getState();
dispatch({
type: SESSION_ADD,
uid,
shell,
pid
pid,
cols,
rows,
splitDirection,
activeUid: sessions.activeUid
});
};
}
@ -72,15 +77,16 @@ export function addSessionData(uid, data) {
};
}
export function sessionExit(uid) {
return (dispatch, getState) => {
function createExitAction(type) {
return uid => (dispatch, getState) => {
return dispatch({
type: SESSION_PTY_EXIT,
type,
uid,
effect() {
// we reiterate the same logic as below
// for SESSION_USER_EXIT since the exit
// could happen pty side or optimistic
if (type === SESSION_USER_EXIT) {
rpc.emit('exit', {uid});
}
const sessions = keys(getState().sessions.sessions);
if (!sessions.length) {
window.close();
@ -92,33 +98,8 @@ export function sessionExit(uid) {
// 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 const userExitSession = createExitAction(SESSION_USER_EXIT);
export const ptyExitSession = createExitAction(SESSION_PTY_EXIT);
export function setActiveSession(uid) {
return (dispatch, getState) => {
@ -163,13 +144,20 @@ export function setSessionXtermTitle(uid, title) {
}
export function resizeSession(uid, cols, rows) {
return {
type: SESSION_RESIZE,
cols,
rows,
effect() {
rpc.emit('resize', {cols, rows});
}
return (dispatch, getState) => {
const {termGroups} = getState();
const group = findBySession(termGroups, uid);
const isStandaloneTerm = !group.parentUid && !group.children.length;
dispatch({
type: SESSION_RESIZE,
uid,
cols,
rows,
isStandaloneTerm,
effect() {
rpc.emit('resize', {uid, cols, rows});
}
});
};
}

173
lib/actions/term-groups.js Normal file
View file

@ -0,0 +1,173 @@
import rpc from '../rpc';
import {
DIRECTION,
TERM_GROUP_RESIZE,
TERM_GROUP_REQUEST,
TERM_GROUP_EXIT,
TERM_GROUP_EXIT_ACTIVE
} from '../constants/term-groups';
import {SESSION_REQUEST} from '../constants/sessions';
import {findBySession} from '../utils/term-groups';
import {getRootGroups} from '../selectors';
import {setActiveSession, ptyExitSession, userExitSession} from './sessions';
function requestSplit(direction) {
return () => (dispatch, getState) => {
const {ui} = getState();
dispatch({
type: SESSION_REQUEST,
effect: () => {
rpc.emit('new', {
splitDirection: direction,
cwd: ui.cwd
});
}
});
};
}
export const requestVerticalSplit = requestSplit(DIRECTION.VERTICAL);
export const requestHorizontalSplit = requestSplit(DIRECTION.HORIZONTAL);
export function resizeTermGroup(uid, sizes) {
return {
uid,
type: TERM_GROUP_RESIZE,
sizes
};
}
export function requestTermGroup() {
return (dispatch, getState) => {
const {ui} = getState();
const {cols, rows, cwd} = ui;
dispatch({
type: TERM_GROUP_REQUEST,
effect: () => {
rpc.emit('new', {
isNewGroup: true,
cols,
rows,
cwd
});
}
});
};
}
export function setActiveGroup(uid) {
return (dispatch, getState) => {
const {termGroups} = getState();
dispatch(setActiveSession(termGroups.activeSessions[uid]));
};
}
// When we've found the next group which we want to
// set as active (after closing something), we also need
// to find the first child group which has a sessionUid.
const findFirstSession = (state, group) => {
if (group.sessionUid) {
return group.sessionUid;
}
for (const childUid of group.children) {
const child = state.termGroups[childUid];
// We want to find the *leftmost* session,
// even if it's nested deep down:
const sessionUid = findFirstSession(state, child);
if (sessionUid) {
return sessionUid;
}
}
};
const findPrevious = (list, old) => {
const index = list.indexOf(old);
// If `old` was the first item in the list,
// choose the other item available:
return index ? list[index - 1] : list[1];
};
const findNextSessionUid = (state, group) => {
// If we're closing a root group (i.e. a whole tab),
// the next group needs to be a root group as well:
if (state.activeRootGroup === group.uid) {
const rootGroups = getRootGroups({termGroups: state});
const nextGroup = findPrevious(rootGroups, group);
return findFirstSession(state, nextGroup);
}
const {children} = state.termGroups[group.parentUid];
const nextUid = findPrevious(children, group.uid);
return findFirstSession(state, state.termGroups[nextUid]);
};
export function ptyExitTermGroup(sessionUid) {
return (dispatch, getState) => {
const {termGroups} = getState();
const group = findBySession(termGroups, sessionUid);
// This might have already been closed:
if (!group) {
return dispatch(ptyExitSession(sessionUid));
}
dispatch({
type: TERM_GROUP_EXIT,
uid: group.uid,
effect: () => {
const activeSessionUid = termGroups.activeSessions[termGroups.activeRootGroup];
if (Object.keys(termGroups.termGroups).length > 1 && activeSessionUid === sessionUid) {
const nextSessionUid = findNextSessionUid(termGroups, group);
dispatch(setActiveSession(nextSessionUid));
}
dispatch(ptyExitSession(sessionUid));
}
});
};
}
export function userExitTermGroup(uid) {
return (dispatch, getState) => {
const {termGroups} = getState();
dispatch({
type: TERM_GROUP_EXIT,
uid,
effect: () => {
const group = termGroups.termGroups[uid];
if (Object.keys(termGroups.termGroups).length <= 1) {
// No need to attempt finding a new active session
// if this is the last one we've got:
return dispatch(userExitSession(group.sessionUid));
}
const activeSessionUid = termGroups.activeSessions[termGroups.activeRootGroup];
if (termGroups.activeRootGroup === uid || activeSessionUid === group.sessionUid) {
const nextSessionUid = findNextSessionUid(termGroups, group);
dispatch(setActiveSession(nextSessionUid));
}
if (group.sessionUid) {
dispatch(userExitSession(group.sessionUid));
} else {
group.children.forEach(childUid => {
dispatch(userExitTermGroup(childUid));
});
}
}
});
};
}
export function exitActiveTermGroup() {
return (dispatch, getState) => {
dispatch({
type: TERM_GROUP_EXIT_ACTIVE,
effect() {
const {sessions, termGroups} = getState();
const {uid} = findBySession(termGroups, sessions.activeUid);
dispatch(userExitTermGroup(uid));
}
});
};
}

View file

@ -1,13 +1,14 @@
import * as shellEscape from 'php-escape-shell';
import {keys} from '../utils/object';
import {last} from '../utils/array';
import {isExecutable} from '../utils/file';
import {getRootGroups} from '../selectors';
import {findBySession} from '../utils/term-groups';
import notify from '../utils/notify';
import rpc from '../rpc';
import {
requestSession,
sendSessionData
sendSessionData,
setActiveSession
} from '../actions/sessions';
import {
UI_FONT_SIZE_SET,
@ -18,12 +19,14 @@ import {
UI_MOVE_LEFT,
UI_MOVE_RIGHT,
UI_MOVE_TO,
UI_MOVE_NEXT_PANE,
UI_MOVE_PREV_PANE,
UI_SHOW_PREFERENCES,
UI_WINDOW_MOVE,
UI_OPEN_FILE
} from '../constants/ui';
import {setActiveSession} from './sessions';
import {setActiveGroup} from './term-groups';
const {stat} = window.require('fs');
@ -83,20 +86,73 @@ export function setFontSmoothing() {
};
}
// Find all sessions that are below the given
// termGroup uid in the hierarchy:
const findChildSessions = (termGroups, uid) => {
const group = termGroups[uid];
if (group.sessionUid) {
return [uid];
}
return group
.children
.reduce((total, childUid) => total.concat(
findChildSessions(termGroups, childUid)
), []);
};
// Get the index of the next or previous group,
// depending on the movement direction:
const getNeighborIndex = (groups, uid, type) => {
if (type === UI_MOVE_NEXT_PANE) {
return (groups.indexOf(uid) + 1) % groups.length;
}
return (groups.indexOf(uid) + groups.length - 1) % groups.length;
};
function moveToNeighborPane(type) {
return () => (dispatch, getState) => {
dispatch({
type,
effect() {
const {sessions, termGroups} = getState();
const {uid} = findBySession(termGroups, sessions.activeUid);
const childGroups = findChildSessions(termGroups.termGroups, termGroups.activeRootGroup);
if (childGroups.length === 1) {
console.log('ignoring move for single group');
} else {
const index = getNeighborIndex(childGroups, uid, type);
const {sessionUid} = termGroups.termGroups[childGroups[index]];
dispatch(setActiveSession(sessionUid));
}
}
});
};
}
export const moveToNextPane = moveToNeighborPane(UI_MOVE_NEXT_PANE);
export const moveToPreviousPane = moveToNeighborPane(UI_MOVE_PREV_PANE);
const getGroupUids = state => {
const rootGroups = getRootGroups(state);
return rootGroups.map(({uid}) => uid);
};
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);
const state = getState();
const uid = state.termGroups.activeRootGroup;
const groupUids = getGroupUids(state);
const index = groupUids.indexOf(uid);
const next = groupUids[index - 1] || last(groupUids);
if (!next || uid === next) {
console.log('ignoring left move action');
} else {
dispatch(setActiveSession(next));
dispatch(setActiveGroup(next));
}
}
});
@ -108,15 +164,15 @@ export function moveRight() {
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];
const state = getState();
const groupUids = getGroupUids(state);
const uid = state.termGroups.activeRootGroup;
const index = groupUids.indexOf(uid);
const next = groupUids[index + 1] || groupUids[0];
if (!next || uid === next) {
console.log('ignoring right move action');
} else {
dispatch(setActiveSession(next));
dispatch(setActiveGroup(next));
}
}
});
@ -129,15 +185,15 @@ export function moveTo(i) {
type: UI_MOVE_TO,
index: i,
effect() {
const {sessions} = getState();
const uid = sessions.activeUid;
const sessionUids = keys(sessions.sessions);
if (uid === sessionUids[i]) {
const state = getState();
const groupUids = getGroupUids(state);
const uid = state.termGroups.activeRootGroup;
if (uid === groupUids[i]) {
console.log('ignoring same uid');
} else if (sessionUids[i] === null) {
console.log('ignoring inexistent index', i);
} else if (groupUids[i]) {
dispatch(setActiveGroup(groupUids[i]));
} else {
dispatch(setActiveSession(sessionUids[i]));
console.log('ignoring inexistent index', i);
}
}
});

View file

@ -27,7 +27,14 @@ export default class Header extends Component {
this.props.onChangeTab(active);
}
handleHeaderMouseDown() {
handleHeaderMouseDown(ev) {
// the hack of all hacks, this prevents the term
// iframe from losing focus, for example, when
// the user drags the nav around
ev.preventDefault();
// persist start positions of a potential drag motion
// to differentiate dragging from clicking
this.headerMouseDownWindowX = window.screenX;
this.headerMouseDownWindowY = window.screenY;
}

View file

@ -0,0 +1,184 @@
import React from 'react';
import Component from '../component';
export default class SplitPane extends Component {
constructor(props) {
super(props);
this.handleDragStart = this.handleDragStart.bind(this);
this.onDrag = this.onDrag.bind(this);
this.onDragEnd = this.onDragEnd.bind(this);
this.state = {dragging: false};
}
componentDidUpdate(prevProps) {
if (this.state.dragging && prevProps.sizes !== this.props.sizes) {
// recompute positions for ongoing dragging
this.dragPanePosition = this.dragTarget.getBoundingClientRect()[this.d2];
}
}
handleDragStart(ev) {
ev.preventDefault();
this.setState({dragging: true});
window.addEventListener('mousemove', this.onDrag);
window.addEventListener('mouseup', this.onDragEnd);
// dimensions to consider
if (this.props.direction === 'horizontal') {
this.d1 = 'height';
this.d2 = 'top';
this.d3 = 'clientY';
} else {
this.d1 = 'width';
this.d2 = 'left';
this.d3 = 'clientX';
}
this.dragTarget = ev.target;
this.dragPanePosition = this.dragTarget.getBoundingClientRect()[this.d2];
this.panes = Array.from(ev.target.parentNode.childNodes);
this.panesSize = ev.target.parentNode.getBoundingClientRect()[this.d1];
this.paneIndex = this.panes.indexOf(ev.target);
this.paneIndex -= Math.ceil(this.paneIndex / 2);
}
onDrag(ev) {
let {sizes} = this.props;
let sizes_;
if (sizes) {
sizes_ = [].concat(sizes);
} else {
const total = this.props.children.length;
sizes = sizes_ = new Array(total).fill(1 / total);
}
const i = this.paneIndex;
const pos = ev[this.d3];
const d = Math.abs(this.dragPanePosition - pos) / this.panesSize;
if (pos > this.dragPanePosition) {
sizes_[i] += d;
sizes_[i + 1] -= d;
} else {
sizes_[i] -= d;
sizes_[i + 1] += d;
}
this.props.onResize(sizes_);
}
onDragEnd() {
if (this.state.dragging) {
window.removeEventListener('mousemove', this.onDrag);
window.removeEventListener('mouseup', this.onDragEnd);
this.setState({dragging: false});
}
}
template(css) {
const children = this.props.children;
const {direction, borderColor} = this.props;
let {sizes} = this.props;
if (!sizes) {
// workaround for the fact that if we don't specify
// sizes, sometimes flex fails to calculate the
// right height for the horizontal panes
sizes = new Array(children.length).fill(1 / children.length);
}
return (<div className={css('panes', `panes_${direction}`)}>
{
React.Children.map(children, (child, i) => {
const style = {
flexBasis: (sizes[i] * 100) + '%',
flexGrow: 0
};
return [
<div className={css('pane')} style={style}>
{ child }
</div>,
i < children.length - 1 ?
<div
onMouseDown={this.handleDragStart}
style={{backgroundColor: borderColor}}
className={css('divider', `divider_${direction}`)}
/> :
null
];
})
}
<div
style={{display: this.state.dragging ? 'block' : 'none'}}
className={css('shim')}
/>
</div>);
}
styles() {
return {
panes: {
display: 'flex',
flex: 1,
outline: 'none',
position: 'relative',
width: '100%',
height: '100%'
},
'panes_vertical': {
flexDirection: 'row'
},
'panes_horizontal': {
flexDirection: 'column'
},
pane: {
flex: 1,
outline: 'none',
position: 'relative'
},
divider: {
boxSizing: 'border-box',
zIndex: '1',
backgroundClip: 'padding-box',
flexShrink: 0
},
'divider_vertical': {
borderLeft: '5px solid rgba(255, 255, 255, 0)',
borderRight: '5px solid rgba(255, 255, 255, 0)',
width: '11px',
margin: '0 -5px',
cursor: 'col-resize'
},
'divider_horizontal': {
height: '11px',
margin: '-5px 0',
borderTop: '5px solid rgba(255, 255, 255, 0)',
borderBottom: '5px solid rgba(255, 255, 255, 0)',
cursor: 'row-resize',
width: '100%'
},
// this shim is used to make sure mousemove events
// trigger in all the draggable area of the screen
// this is not the case due to hterm's <iframe>
shim: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'transparent'
}
};
}
componentWillUnmount() {
// ensure drag end
if (this.dragging) {
this.onDragEnd();
}
}
}

View file

@ -0,0 +1,118 @@
import React from 'react';
import {connect} from 'react-redux';
import Component from '../component';
import {decorate, getTermProps} from '../utils/plugins';
import {resizeTermGroup} from '../actions/term-groups';
import Term_ from './term';
import SplitPane_ from './split-pane';
const Term = decorate(Term_, 'Term');
const SplitPane = decorate(SplitPane_, 'SplitPane');
class TermGroup_ extends Component {
constructor(props, context) {
super(props, context);
this.bound = new WeakMap();
}
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];
}
renderSplit(groups) {
const [first, ...rest] = groups;
if (!rest.length) {
return first;
}
const direction = this.props.termGroup.direction.toLowerCase();
return (<SplitPane
direction={direction}
sizes={this.props.termGroup.sizes}
onResize={this.props.onTermGroupResize}
borderColor={this.props.borderColor}
>
{ groups }
</SplitPane>);
}
renderTerm(uid) {
const session = this.props.sessions[uid];
const termRef = this.props.terms[uid];
const props = getTermProps(uid, this.props, {
term: termRef ? termRef.term : null,
customCSS: this.props.customCSS,
fontSize: this.props.fontSize,
cursorColor: this.props.cursorColor,
cursorShape: this.props.cursorShape,
fontFamily: this.props.fontFamily,
fontSmoothing: this.props.fontSmoothing,
foregroundColor: this.props.foregroundColor,
backgroundColor: this.props.backgroundColor,
modifierKeys: this.props.modifierKeys,
padding: this.props.padding,
colors: this.props.colors,
url: session.url,
cleared: session.cleared,
cols: session.cols,
rows: session.rows,
onActive: this.bind(this.props.onActive, null, uid),
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)
});
// TODO: This will create a new ref_ function for every render,
// which is inefficient. Should maybe do something similar
// to this.bind.
return (<Term
ref_={term => this.props.ref_(uid, term)}
key={uid}
{...props}
/>);
}
template() {
const {childGroups, termGroup} = this.props;
if (termGroup.sessionUid) {
return this.renderTerm(termGroup.sessionUid);
}
const groups = childGroups.map(child => {
const props = Object.assign({}, this.props, {
termGroup: child
});
return (<TermGroup
key={child.uid}
{...props}
/>);
});
return this.renderSplit(groups);
}
}
const TermGroup = connect(
(state, ownProps) => ({
childGroups: ownProps.termGroup.children.map(uid =>
state.termGroups.termGroups[uid]
)
}),
(dispatch, ownProps) => ({
onTermGroupResize(splitSizes) {
dispatch(resizeTermGroup(ownProps.termGroup.uid, splitSizes));
}
})
)(TermGroup_);
export default TermGroup;

View file

@ -10,15 +10,17 @@ export default class Term extends Component {
constructor(props) {
super(props);
this.onWheel = this.onWheel.bind(this);
this.handleWheel = this.handleWheel.bind(this);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleScrollEnter = this.handleScrollEnter.bind(this);
this.handleScrollLeave = this.handleScrollLeave.bind(this);
this.handleFocus = this.handleFocus.bind(this);
props.ref_(this);
}
componentDidMount() {
const {props} = this;
this.term = new hterm.Terminal();
this.term = props.term || new hterm.Terminal();
// the first term that's created has unknown size
// subsequent new tabs have size
@ -72,10 +74,12 @@ export default class Term extends Component {
}
const iframeWindow = this.getTermDocument().defaultView;
iframeWindow.addEventListener('wheel', this.onWheel);
iframeWindow.addEventListener('wheel', this.handleWheel);
this.getScreenNode().addEventListener('focus', this.handleFocus);
}
onWheel(e) {
handleWheel(e) {
if (this.props.onWheel) {
this.props.onWheel(e);
}
@ -99,6 +103,13 @@ export default class Term extends Component {
this.scrollMouseEnter = false;
}
handleFocus() {
// TODO: This will in turn result in `this.focus()` being
// called, which is unecessary.
// Should investigate if it matters.
this.props.onActive();
}
write(data) {
requestAnimationFrame(() => {
this.term.io.writeUTF8(data);
@ -152,6 +163,10 @@ export default class Term extends Component {
this.term.selectAll();
}
getScreenNode() {
return this.term.scrollPort_.getScreenNode();
}
getTermDocument() {
return this.term.document_;
}
@ -175,6 +190,15 @@ export default class Term extends Component {
return alternative;
}
handleMouseDown(ev) {
// we prevent losing focus when clicking the boundary
// wrappers of the main terminal element
if (ev.target === this.refs.term_wrapper ||
ev.target === this.refs.term) {
ev.preventDefault();
}
}
componentWillReceiveProps(nextProps) {
if (this.props.url !== nextProps.url) {
// when the url prop changes, we make sure
@ -248,12 +272,18 @@ export default class Term extends Component {
}
template(css) {
return (<div className={css('fit')}>
return (<div
ref="term_wrapper"
className={css('fit')}
onMouseDown={this.handleMouseDown}
style={{padding: this.props.padding}}
>
{ this.props.customChildrenBefore }
<div ref="term" className={css('fit', 'term')}/>
{ this.props.url ?
<webview
src={this.props.url}
onFocus={this.handleFocus}
style={{
background: '#000',
position: 'absolute',
@ -277,6 +307,7 @@ export default class Term extends Component {
styles() {
return {
fit: {
display: 'block',
width: '100%',
height: '100%'
},

View file

@ -1,12 +1,9 @@
import React from 'react';
import Component from '../component';
import {last} from '../utils/array';
import {decorate, getTermProps} from '../utils/plugins';
import {decorate, getTermGroupProps} from '../utils/plugins';
import TermGroup_ from './term-group';
import Term_ from './term';
const Term = decorate(Term_, 'Term');
const TermGroup = decorate(TermGroup_, 'TermGroup');
export default class Terms extends Component {
@ -23,41 +20,6 @@ export default class Terms extends Component {
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) !== -1) {
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) {
@ -83,7 +45,7 @@ export default class Terms extends Component {
onRef(uid, term) {
if (term) {
this.terms[uid] = term;
} else {
} else if (!this.props.sessions[uid]) {
delete this.terms[uid];
}
}
@ -100,21 +62,6 @@ export default class Terms extends Component {
return this.props.sessions.length - 1;
}
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;
}
@ -125,46 +72,51 @@ export default class Terms extends Component {
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,
this.props.termGroups.map(termGroup => {
const {uid} = termGroup;
const isActive = uid === this.props.activeRootGroup;
const props = getTermGroupProps(uid, this.props, {
termGroup,
terms: this.terms,
sessions: this.props.sessions,
customCSS: this.props.customCSS,
fontSize: this.props.fontSize,
borderColor: this.props.borderColor,
cursorColor: this.props.cursorColor,
cursorShape: this.props.cursorShape,
fontFamily: this.props.fontFamily,
fontSmoothing: this.props.fontSmoothing,
foregroundColor: this.props.foregroundColor,
backgroundColor: this.props.backgroundColor,
padding: this.props.padding,
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),
bell: this.props.bell,
bellSoundURL: this.props.bellSoundURL,
copyOnSelect: this.props.copyOnSelect,
modifierKeys: this.props.modifierKeys
modifierKeys: this.props.modifierKeys,
onActive: this.props.onActive,
onResize: this.props.onResize,
onTitle: this.props.onTitle,
onData: this.props.onData,
onURLAbort: this.props.onURLAbort
});
return (<div
key={`d${uid}`}
className={css('term', isActive && 'termActive')}
>
<Term
key={uid}
ref_={this.bind(this.onRef, this, uid)}
{...props}
/>
</div>);
return (
<div
key={`d${uid}`}
className={css('termGroup', isActive && 'termGroupActive')}
>
<TermGroup
key={uid}
ref_={this.onRef}
{...props}
/>
</div>
);
})
}
{ this.props.customChildren }
@ -183,24 +135,15 @@ export default class Terms extends Component {
color: '#fff'
},
term: {
termGroup: {
display: 'none',
width: '100%',
height: '100%'
},
termActive: {
termGroupActive: {
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

@ -5,7 +5,6 @@ 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';

View file

@ -0,0 +1,8 @@
export const TERM_GROUP_REQUEST = 'TERM_GROUP_REQUEST';
export const TERM_GROUP_EXIT = 'TERM_GROUP_EXIT';
export const TERM_GROUP_RESIZE = 'TERM_GROUP_RESIZE';
export const TERM_GROUP_EXIT_ACTIVE = 'TERM_GROUP_EXIT_ACTIVE';
export const DIRECTION = {
HORIZONTAL: 'HORIZONTAL',
VERTICAL: 'VERTICAL'
};

View file

@ -6,6 +6,8 @@ export const UI_FONT_SMOOTHING_SET = 'UI_FONT_SMOOTHING_SET';
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_MOVE_NEXT_PANE = 'UI_MOVE_NEXT_PANE';
export const UI_MOVE_PREV_PANE = 'UI_MOVE_PREV_PANE';
export const UI_SHOW_PREFERENCES = 'UI_SHOW_PREFERENCES';
export const UI_WINDOW_MOVE = 'UI_WINDOW_MOVE';
export const UI_WINDOW_MAXIMIZE = 'UI_WINDOW_MAXIMIZE';

View file

@ -1,23 +1,27 @@
/* eslint-disable max-params */
import {createSelector} from 'reselect';
import Header from '../components/header';
import {closeTab, changeTab, maximize, unmaximize} from '../actions/header';
import {values} from '../utils/object';
import {connect} from '../utils/plugins';
import {getRootGroups} from '../selectors';
const isMac = /Mac/.test(navigator.userAgent);
const getSessions = sessions => sessions.sessions;
const getActiveUid = sessions => sessions.activeUid;
const getActivityMarkers = (sessions, ui) => ui.activityMarkers;
const getSessions = ({sessions}) => sessions.sessions;
const getActiveRootGroup = ({termGroups}) => termGroups.activeRootGroup;
const getActiveSessions = ({termGroups}) => termGroups.activeSessions;
const getActivityMarkers = ({ui}) => ui.activityMarkers;
const getTabs = createSelector(
[getSessions, getActiveUid, getActivityMarkers],
(sessions, activeUid, activityMarkers) => values(sessions).map(s => {
[getSessions, getRootGroups, getActiveSessions, getActiveRootGroup, getActivityMarkers],
(sessions, rootGroups, activeSessions, activeRootGroup, activityMarkers) => rootGroups.map(t => {
const activeSessionUid = activeSessions[t.uid];
const session = sessions[activeSessionUid];
return {
uid: s.uid,
title: s.title,
isActive: s.uid === activeUid,
hasActivity: activityMarkers[s.uid]
uid: t.uid,
title: session.title,
isActive: t.uid === activeRootGroup,
hasActivity: activityMarkers[session.uid]
};
})
);
@ -27,7 +31,7 @@ const HeaderContainer = connect(
return {
// active is an index
isMac,
tabs: getTabs(state.sessions, state.ui),
tabs: getTabs(state),
activeMarkers: state.ui.activityMarkers,
borderColor: state.ui.borderColor,
backgroundColor: state.ui.backgroundColor,

View file

@ -4,6 +4,7 @@ import React from 'react';
import Component from '../component';
import {connect} from '../utils/plugins';
import * as uiActions from '../actions/ui';
import * as termGroupActions from '../actions/term-groups';
import HeaderContainer from './header';
import TermsContainer from './terms';
@ -34,7 +35,7 @@ class HyperTerm extends Component {
}
attachKeyListeners() {
const {moveTo, moveLeft, moveRight} = this.props;
const {moveTo, moveLeft, moveRight, splitHorizontal, splitVertical} = this.props;
const term = this.terms.getActiveTerm();
if (!term) {
return;
@ -42,6 +43,11 @@ class HyperTerm extends Component {
const lastIndex = this.terms.getLastTermIndex();
const document = term.getTermDocument();
const keys = new Mousetrap(document);
if (process.platform === 'darwin') {
keys.bind('command+d', splitVertical);
keys.bind('command+shift+d', splitHorizontal);
}
keys.bind('command+1', moveTo.bind(this, 0));
keys.bind('command+2', moveTo.bind(this, 1));
keys.bind('command+3', moveTo.bind(this, 2));
@ -96,7 +102,7 @@ class HyperTerm extends Component {
template(css) {
const {isMac, customCSS, borderColor} = this.props;
return (<div onClick={this.handleFocusActive}>
return (<div>
<div
style={{borderColor}}
className={css('main', isMac && 'mainRounded')}
@ -152,6 +158,14 @@ const HyperTermContainer = connect(
moveRight: () => {
dispatch(uiActions.moveRight());
},
splitHorizontal: () => {
dispatch(termGroupActions.requestHorizontalSplit());
},
splitVertical: () => {
dispatch(termGroupActions.requestVerticalSplit());
}
};
},

View file

@ -1,5 +1,4 @@
import Terms from '../components/terms';
import {values} from '../utils/object';
import {connect} from '../utils/plugins';
import {
resizeSession,
@ -8,14 +7,17 @@ import {
setSessionXtermTitle,
setActiveSession
} from '../actions/sessions';
import {getRootGroups} from '../selectors';
const TermsContainer = connect(
state => {
const sessions = state.sessions.sessions;
const {sessions} = state.sessions;
return {
sessions,
cols: state.ui.cols,
rows: state.ui.rows,
sessions: values(sessions),
termGroups: getRootGroups(state),
activeRootGroup: state.termGroups.activeRootGroup,
activeSession: state.sessions.activeUid,
customCSS: state.ui.termCSS,
write: state.sessions.write,

View file

@ -3,6 +3,14 @@ import fromCharCode from './utils/key-code';
const selection = require('./utils/selection');
// Keys that are used in combination
// with ctrl based hotkeys:
const IGNORED_CTRL_KEYS = [
'Tab',
'KeyO',
'KeyE'
];
hterm.defaultStorage = new lib.Storage.Memory();
// Provide selectAll to terminal viewport
@ -129,7 +137,7 @@ hterm.Keyboard.prototype.onKeyDown_ = function (e) {
e.preventDefault();
}
if (e.metaKey || e.altKey || (e.ctrlKey && e.code === 'Tab')) {
if (e.metaKey || e.altKey || (e.ctrlKey && IGNORED_CTRL_KEYS.includes(e.code))) {
return;
}
if ((!e.ctrlKey || e.code !== 'ControlLeft') && !e.shiftKey && e.code !== 'CapsLock') {
@ -191,6 +199,42 @@ hterm.Terminal.prototype.clearPreserveCursorRow = function () {
this.scrollPort_.redraw_();
};
const oldOnMouse = hterm.Terminal.prototype.onMouse_;
hterm.Terminal.prototype.onMouse_ = function (e) {
// override `preventDefault` to not actually
// prevent default when the type of event is
// mousedown, so that we can still trigger
// focus on the terminal when the underlying
// VT is interested in mouse events, as is the
// case of programs like `vtop` that allow for
// the user to click on rows
if (e.type === 'mousedown') {
e.preventDefault = function () { };
}
return oldOnMouse.call(this, e);
};
// sine above we're no longer relying on `preventDefault`
// to avoid selections, we use css instead, so that
// focus is not lost, but selections are still not possible
// when the appropiate VT mode is set
hterm.VT.prototype.__defineSetter__('mouseReport', function (val) {
this.mouseReport_ = val;
const rowNodes = this.terminal.scrollPort_.rowNodes_;
if (rowNodes) {
if (val === this.MOUSE_REPORT_DISABLED) {
rowNodes.style.webkitUserSelect = 'text';
} else {
rowNodes.style.webkitUserSelect = 'none';
}
}
});
hterm.VT.prototype.__defineGetter__('mouseReport', function () {
return this.mouseReport_;
});
// fixes a bug in hterm, where the shorthand hex
// is not properly converted to rgb
lib.colors.hexToRGB = function (arg) {

View file

@ -11,6 +11,7 @@ import * as plugins from './utils/plugins';
import * as uiActions from './actions/ui';
import * as updaterActions from './actions/updater';
import * as sessionActions from './actions/sessions';
import * as termGroupActions from './actions/term-groups';
import HyperTermContainer from './containers/hyperterm';
import {loadConfig, reloadConfig} from './actions/config';
import configureStore from './store/configure-store';
@ -38,8 +39,8 @@ rpc.on('ready', () => {
store_.dispatch(uiActions.setFontSmoothing());
});
rpc.on('session add', ({uid, shell, pid}) => {
store_.dispatch(sessionActions.addSession(uid, shell, pid));
rpc.on('session add', data => {
store_.dispatch(sessionActions.addSession(data));
});
rpc.on('session data', ({uid, data}) => {
@ -55,21 +56,29 @@ rpc.on('session title', ({uid, title}) => {
});
rpc.on('session exit', ({uid}) => {
store_.dispatch(sessionActions.sessionExit(uid));
store_.dispatch(termGroupActions.ptyExitTermGroup(uid));
});
rpc.on('session add req', () => {
store_.dispatch(sessionActions.requestSession());
});
rpc.on('session close req', () => {
store_.dispatch(sessionActions.userExitActiveSession());
rpc.on('termgroup close req', () => {
store_.dispatch(termGroupActions.exitActiveTermGroup());
});
rpc.on('session clear req', () => {
store_.dispatch(sessionActions.clearActiveSession());
});
rpc.on('termgroup add req', () => {
store_.dispatch(termGroupActions.requestTermGroup());
});
rpc.on('split request horizontal', () => {
store_.dispatch(termGroupActions.requestHorizontalSplit());
});
rpc.on('split request vertical', () => {
store_.dispatch(termGroupActions.requestVerticalSplit());
});
rpc.on('reset fontSize req', () => {
store_.dispatch(uiActions.resetFontSize());
});
@ -90,6 +99,14 @@ rpc.on('move right req', () => {
store_.dispatch(uiActions.moveRight());
});
rpc.on('next pane req', () => {
store_.dispatch(uiActions.moveToNextPane());
});
rpc.on('prev pane req', () => {
store_.dispatch(uiActions.moveToPreviousPane());
});
rpc.on('preferences', () => {
store_.dispatch(uiActions.showPreferences());
});

View file

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

View file

@ -9,6 +9,7 @@ import {
SESSION_CLEAR_ACTIVE,
SESSION_URL_SET,
SESSION_URL_UNSET,
SESSION_RESIZE,
SESSION_SET_XTERM_TITLE,
SESSION_SET_PROCESS_TITLE
} from '../constants/sessions';
@ -23,6 +24,8 @@ function Session(obj) {
return Immutable({
uid: '',
title: '',
cols: null,
rows: null,
write: null,
url: null,
cleared: false,
@ -41,11 +44,15 @@ function Write(obj) {
const reducer = (state = initialState, action) => {
switch (action.type) {
case SESSION_ADD:
return state.setIn(['sessions', action.uid], Session({
uid: action.uid,
shell: action.shell.split('/').pop(),
pid: action.pid
}));
return state
.set('activeUid', action.uid)
.setIn(['sessions', action.uid], Session({
cols: action.cols,
rows: action.rows,
uid: action.uid,
shell: action.shell.split('/').pop(),
pid: action.pid
}));
case SESSION_URL_SET:
return state.setIn(['sessions', action.uid, 'url'], action.url);
@ -90,6 +97,13 @@ const reducer = (state = initialState, action) => {
case SESSION_SET_PROCESS_TITLE:
return state.setIn(['sessions', action.uid, 'title'], action.title);
case SESSION_RESIZE:
return state.setIn(['sessions', action.uid], state.sessions[action.uid].merge({
rows: action.rows,
cols: action.cols,
resizeAt: Date.now()
}));
default:
return state;
}

223
lib/reducers/term-groups.js Normal file
View file

@ -0,0 +1,223 @@
import uuid from 'uuid';
import Immutable from 'seamless-immutable';
import {TERM_GROUP_EXIT, TERM_GROUP_RESIZE} from '../constants/term-groups';
import {SESSION_ADD, SESSION_SET_ACTIVE} from '../constants/sessions';
import {findBySession} from '../utils/term-groups';
import {decorateTermGroupsReducer} from '../utils/plugins';
const MIN_SIZE = 0.05;
const initialState = Immutable({
termGroups: {},
activeSessions: {},
activeRootGroup: null
});
function TermGroup(obj) {
return Immutable({
uid: null,
sessionUid: null,
parentUid: null,
direction: null,
sizes: null,
children: []
}).merge(obj);
}
// Recurse upwards until we find a root term group (no parent).
const findRootGroup = (termGroups, uid) => {
const current = termGroups[uid];
if (!current.parentUid) {
return current;
}
return findRootGroup(termGroups, current.parentUid);
};
const setActiveGroup = (state, action) => {
if (!action.uid) {
return state.set('activeRootGroup', null);
}
const childGroup = findBySession(state, action.uid);
const rootGroup = findRootGroup(state.termGroups, childGroup.uid);
return state
.set('activeRootGroup', rootGroup.uid)
.setIn(['activeSessions', rootGroup.uid], action.uid);
};
// Reduce existing sizes to fit a new split:
const insertRebalance = (oldSizes, index) => {
const newSize = 1 / (oldSizes.length + 1);
// We spread out how much each pane should be reduced
// with based on their existing size:
const balanced = oldSizes.map(size => size - (newSize * size));
return [...balanced.slice(0, index), newSize, ...balanced.slice(index)];
};
// Spread out the removed size to all the existing sizes:
const removalRebalance = (oldSizes, index) => {
const removedSize = oldSizes[index];
const increase = removedSize / (oldSizes.length - 1);
return oldSizes
.filter((_size, i) => i !== index)
.map(size => size + increase);
};
const splitGroup = (state, action) => {
const {splitDirection, uid, activeUid} = action;
const activeGroup = findBySession(state, activeUid);
// If we're splitting in the same direction as the current active
// group's parent - or if it's the first split for that group -
// we want the parent to get another child:
let parentGroup = activeGroup.parentUid ? state.termGroups[activeGroup.parentUid] : activeGroup;
// If we're splitting in a different direction, we want the current
// active group to become a new parent instead:
if (parentGroup.direction && parentGroup.direction !== splitDirection) {
parentGroup = activeGroup;
}
// If the group has a session (i.e. we're creating a new parent)
// we need to create two new groups,
// one for the existing session and one for the new split:
// P
// P -> / \
// G G
const newSession = TermGroup({
uid: uuid.v4(),
sessionUid: uid,
parentUid: parentGroup.uid
});
state = state.setIn(['termGroups', newSession.uid], newSession);
if (parentGroup.sessionUid) {
const existingSession = TermGroup({
uid: uuid.v4(),
sessionUid: parentGroup.sessionUid,
parentUid: parentGroup.uid
});
return state
.setIn(['termGroups', existingSession.uid], existingSession)
.setIn(['termGroups', parentGroup.uid], parentGroup.merge({
sessionUid: null,
direction: splitDirection,
children: [existingSession.uid, newSession.uid]
}));
}
const {children} = parentGroup;
// Insert the new child pane right after the active one:
const index = children.indexOf(activeGroup.uid) + 1;
const newChildren = [...children.slice(0, index), newSession.uid, ...children.slice(index)];
state = state.setIn(['termGroups', parentGroup.uid], parentGroup.merge({
direction: splitDirection,
children: newChildren
}));
if (parentGroup.sizes) {
const newSizes = insertRebalance(parentGroup.sizes, index);
state = state.setIn(['termGroups', parentGroup.uid, 'sizes'], newSizes);
}
return state;
};
// Replace the parent by the given child in the tree,
// used when we remove another child and we're left
// with a one-to-one mapping between parent and child.
const replaceParent = (state, parent, child) => {
if (parent.parentUid) {
const parentParent = state.termGroups[parent.parentUid];
// If the parent we're replacing has a parent,
// we need to change the uid in its children array
// with `child`:
const newChildren = parentParent.children.map(uid =>
uid === parent.uid ? child.uid : uid
);
state = state.setIn(['termGroups', parentParent.uid, 'children'], newChildren);
} else {
// This means the given child will be
// a root group, so we need to set it up as such:
const newSessions = state.activeSessions
.without(parent.uid)
.set(child.uid, state.activeSessions[parent.uid]);
state = state
.set('activeTermGroup', child.uid)
.set('activeSessions', newSessions);
}
return state
.set('termGroups', state.termGroups.without(parent.uid))
.setIn(['termGroups', child.uid, 'parentUid'], parent.parentUid);
};
const removeGroup = (state, uid) => {
const group = state.termGroups[uid];
if (group.parentUid) {
const parent = state.termGroups[group.parentUid];
const newChildren = parent.children.filter(childUid => childUid !== uid);
if (newChildren.length === 1) {
// Since we only have one child left,
// we can merge the parent and child into one group:
const child = state.termGroups[newChildren[0]];
state = replaceParent(state, parent, child);
} else {
state = state.setIn(['termGroups', group.parentUid, 'children'], newChildren);
if (parent.sizes) {
const childIndex = parent.children.indexOf(uid);
const newSizes = removalRebalance(parent.sizes, childIndex);
state = state.setIn(['termGroups', group.parentUid, 'sizes'], newSizes);
}
}
}
return state
.set('termGroups', state.termGroups.without(uid))
.set('activeSessions', state.activeSessions.without(uid));
};
const resizeGroup = (state, uid, sizes) => {
// Make sure none of the sizes fall below MIN_SIZE:
if (sizes.find(size => size < MIN_SIZE)) {
return state;
}
return state.setIn(
['termGroups', uid, 'sizes'],
sizes
);
};
const reducer = (state = initialState, action) => {
switch (action.type) {
case SESSION_ADD: {
if (action.splitDirection) {
state = splitGroup(state, action);
return setActiveGroup(state, action);
}
const uid = uuid.v4();
const termGroup = TermGroup({
uid,
sessionUid: action.uid
});
return state
.setIn(['termGroups', uid], termGroup)
.setIn(['activeSessions', uid], action.uid)
.set('activeRootGroup', uid);
}
case SESSION_SET_ACTIVE:
return setActiveGroup(state, action);
case TERM_GROUP_RESIZE:
return resizeGroup(state, action.uid, action.sizes);
case TERM_GROUP_EXIT:
return removeGroup(state, action.uid);
default:
return state;
}
};
export default decorateTermGroupsReducer(reducer);

View file

@ -178,6 +178,12 @@ const reducer = (state = initial, action) => {
break;
case SESSION_RESIZE:
// We only care about the sizes
// of standalone terms (i.e. not splits):
if (!action.isStandaloneTerm) {
break;
}
state_ = state.merge({
rows: action.rows,
cols: action.cols,

9
lib/selectors.js Normal file
View file

@ -0,0 +1,9 @@
import {createSelector} from 'reselect';
const getTermGroups = ({termGroups}) => termGroups.termGroups;
export const getRootGroups = createSelector(
getTermGroups,
termGroups => Object.keys(termGroups)
.map(uid => termGroups[uid])
.filter(({parentUid}) => !parentUid)
);

View file

@ -29,9 +29,11 @@ let connectors;
let middlewares;
let uiReducers;
let sessionsReducers;
let termGroupsReducers;
let tabPropsDecorators;
let tabsPropsDecorators;
let termPropsDecorators;
let termGroupPropsDecorators;
// the fs locations where usr plugins are stored
const {path, localPath} = plugins.getBasePaths();
@ -69,9 +71,11 @@ const loadModules = () => {
uiReducers = [];
middlewares = [];
sessionsReducers = [];
termGroupsReducers = [];
tabPropsDecorators = [];
tabsPropsDecorators = [];
termPropsDecorators = [];
termGroupPropsDecorators = [];
modules = paths.plugins.concat(paths.localPlugins)
.map(path => {
@ -138,6 +142,10 @@ const loadModules = () => {
connectors.Notifications.dispatch.push(mod.mapNotificationsDispatch);
}
if (mod.getTermGroupProps) {
termGroupPropsDecorators.push(mod.getTermGroupProps);
}
if (mod.getTermProps) {
termPropsDecorators.push(mod.getTermProps);
}
@ -170,6 +178,35 @@ export function reload() {
decorated = {};
}
export function getTermGroupProps(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 \`getTermGroupProps\`. Check Developer Tools for details.`);
return;
}
if (!ret_ || typeof ret_ !== 'object') {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`getTermGroupProps\` (object expected).`);
return;
}
props = ret_;
});
return props_ || props;
}
export function getTermProps(uid, parentProps, props) {
let props_;
@ -313,6 +350,33 @@ export function connect(stateFn, dispatchFn, c, d = {}) {
};
}
export function decorateTermGroupsReducer(fn) {
return (state, action) => {
let state_ = fn(state, action);
termGroupsReducers.forEach(pluginReducer => {
let state__;
try {
state__ = pluginReducer(state_, action);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`reduceTermGroups\`. Check Developer Tools for details.`);
return;
}
if (!state__ || typeof state__ !== 'object') {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`reduceTermGroups\`.`);
return;
}
state_ = state__;
});
return state_;
};
}
export function decorateUIReducer(fn) {
return (state, action) => {
let state_ = fn(state, action);

6
lib/utils/term-groups.js Normal file
View file

@ -0,0 +1,6 @@
export function findBySession(termGroupState, sessionUid) {
const {termGroups} = termGroupState;
return Object.keys(termGroups)
.map(uid => termGroups[uid])
.find(group => group.sessionUid === sessionUid);
}

View file

@ -28,6 +28,7 @@
"redux": "3.6.0",
"redux-thunk": "2.1.0",
"reselect": "2.5.4",
"uuid": "2.0.2",
"seamless-immutable": "6.1.3"
},
"devDependencies": {
@ -38,7 +39,7 @@
"babel-preset-react": "^6.11.1",
"concurrently": "^3.0.0",
"copy-webpack-plugin": "^3.0.1",
"electron": "1.4.2",
"electron": "1.4.0",
"electron-builder": "^7.0.1",
"electron-devtools-installer": "^2.0.0",
"eslint-config-xo-react": "^0.10.0",