From 1a28d2a3be4b9474c397fc7876ea44278de7703e Mon Sep 17 00:00:00 2001 From: Martin Ek Date: Sun, 24 Jul 2016 16:36:37 +0200 Subject: [PATCH 01/42] npm: add .npmrc with save-exact=true --- .npmrc | 1 + app/.npmrc | 1 + 2 files changed, 2 insertions(+) create mode 100644 .npmrc create mode 100644 app/.npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000000..cffe8cdef132 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-exact=true diff --git a/app/.npmrc b/app/.npmrc new file mode 100644 index 000000000000..cffe8cdef132 --- /dev/null +++ b/app/.npmrc @@ -0,0 +1 @@ +save-exact=true From 02c3f6008632ff0430ba776b54aba32c4825de56 Mon Sep 17 00:00:00 2001 From: Martin Ek Date: Wed, 10 Aug 2016 22:23:47 +0200 Subject: [PATCH 02/42] split panes: create initial implementation This allows users to split their Hyperterm terms into multiple nested splits, both vertical and horizontal. Fixes #56 --- app/index.js | 14 +++-- app/menu.js | 23 ++++++- lib/actions/header.js | 5 +- lib/actions/sessions.js | 38 +++++++++--- lib/actions/term-groups.js | 64 ++++++++++++++++++++ lib/actions/ui.js | 44 ++++++++------ lib/components/term-group.js | 111 ++++++++++++++++++++++++++++++++++ lib/components/term.js | 22 ++++++- lib/components/terms.js | 106 +++++++------------------------- lib/constants/term-groups.js | 6 ++ lib/containers/header.js | 23 +++---- lib/containers/hyperterm.js | 4 +- lib/containers/terms.js | 8 ++- lib/index.js | 25 ++++++-- lib/reducers/index.js | 4 +- lib/reducers/sessions.js | 24 ++++++-- lib/reducers/term-groups.js | 114 +++++++++++++++++++++++++++++++++++ lib/reducers/ui.js | 3 + lib/selectors.js | 9 +++ lib/split-pane-styling.js | 47 +++++++++++++++ lib/utils/plugins.js | 64 ++++++++++++++++++++ lib/utils/term-groups.js | 11 ++++ package.json | 1 + 23 files changed, 620 insertions(+), 150 deletions(-) create mode 100644 lib/actions/term-groups.js create mode 100644 lib/components/term-group.js create mode 100644 lib/constants/term-groups.js create mode 100644 lib/reducers/term-groups.js create mode 100644 lib/selectors.js create mode 100644 lib/split-pane-styling.js create mode 100644 lib/utils/term-groups.js diff --git a/app/index.js b/app/index.js index aa51e29b9d02..99df2378b285 100644 --- a/app/index.js +++ b/app/index.js @@ -127,7 +127,7 @@ app.on('ready', () => { // 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'); + if (!fn) fn = (win) => win.rpc.emit('termgroup add req'); // app.windowCallback is the createWindow callback // that can be setted before the 'ready' app event @@ -144,14 +144,17 @@ app.on('ready', () => { } }); - 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 }); @@ -214,10 +217,9 @@ app.on('ready', () => { 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 }) => { diff --git a/app/menu.js b/app/menu.js index ce75dcf15eed..cdf7537c1ff8 100644 --- a/app/menu.js +++ b/app/menu.js @@ -70,7 +70,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(); } @@ -79,6 +79,27 @@ module.exports = function createMenu ({ createWindow, updatePlugins }) { { type: 'separator' }, + { + label: 'Split Vertically', + accelerator: 'CmdOrCtrl+D', + click (item, focusedWindow) { + if (focusedWindow) { + focusedWindow.rpc.emit('split request vertical'); + } + } + }, + { + label: 'Split Horizontally', + accelerator: 'CmdOrCtrl+Shift+D', + click (item, focusedWindow) { + if (focusedWindow) { + focusedWindow.rpc.emit('split request horizontal'); + } + } + }, + { + type: 'separator' + }, { label: 'Close', accelerator: 'CmdOrCtrl+W', diff --git a/lib/actions/header.js b/lib/actions/header.js index 2278c1d21574..f2bb4d544c2c 100644 --- a/lib/actions/header.js +++ b/lib/actions/header.js @@ -1,7 +1,8 @@ import { CLOSE_TAB, CHANGE_TAB } from '../constants/tabs'; import { UI_WINDOW_MAXIMIZE, UI_WINDOW_UNMAXIMIZE } from '../constants/ui'; -import { userExitSession, setActiveSession } from './sessions'; import rpc from '../rpc'; +import { userExitSession } from './sessions'; +import { setActiveGroup } from './term-groups'; export function closeTab (uid) { return (dispatch, getState) => { @@ -21,7 +22,7 @@ export function changeTab (uid) { type: CHANGE_TAB, uid, effect () { - dispatch(setActiveSession(uid)); + dispatch(setActiveGroup(uid)); } }); }; diff --git a/lib/actions/sessions.js b/lib/actions/sessions.js index 0e3683584b0e..401c97a874da 100644 --- a/lib/actions/sessions.js +++ b/lib/actions/sessions.js @@ -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, @@ -19,18 +20,28 @@ import { SESSION_SET_PROCESS_TITLE } from '../constants/sessions'; -export function addSession (uid, shell, pid) { +export function addSession (uid, shell, pid, cols, rows, splitDirection) { return (dispatch, getState) => { + const { sessions } = getState(); + // normally this would be encoded as an effect + // but the `SESSION_ADD` action is pretty expensive + // and we want to get this out as soon as possible + const { activeUid } = sessions; dispatch({ type: SESSION_ADD, uid, shell, - pid + pid, + cols, + rows, + // TODO: These are a bit out of place: + activeUid, + splitDirection }); }; } -export function requestSession (uid) { +export function requestSession () { return (dispatch, getState) => { const { ui } = getState(); const { cols, rows, cwd } = ui; @@ -161,13 +172,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 }); + } + }); }; } diff --git a/lib/actions/term-groups.js b/lib/actions/term-groups.js new file mode 100644 index 000000000000..0804030a9f6f --- /dev/null +++ b/lib/actions/term-groups.js @@ -0,0 +1,64 @@ +import rpc from '../rpc'; +import { + DIRECTION, + TERM_GROUP_REQUEST, + TERM_GROUP_SPLIT +} from '../constants/term-groups'; +import { SESSION_REQUEST } from '../constants/sessions'; +import { setActiveSession } 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 createSplit (uid, direction) { + return (dispatch, getState) => { + const { sessions } = getState(); + dispatch({ + type: TERM_GROUP_SPLIT, + activeUid: sessions.activeUid, + direction, + uid + }); + }; +} + +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(); + const group = termGroups.termGroups[uid]; + dispatch(setActiveSession(group.activeSessionUid)); + }; +} diff --git a/lib/actions/ui.js b/lib/actions/ui.js index 3c1ba9f5cd04..70f1b5ce32bd 100644 --- a/lib/actions/ui.js +++ b/lib/actions/ui.js @@ -1,8 +1,8 @@ import * as shellEscape from 'php-escape-shell'; -import { setActiveSession } from './sessions'; -import { keys } from '../utils/object'; import { last } from '../utils/array'; import { isExecutable } from '../utils/file'; +import { setActiveGroup } from './term-groups'; +import { getRootGroups } from '../selectors'; import notify from '../utils/notify'; import rpc from '../rpc'; import { @@ -86,15 +86,16 @@ export function moveLeft () { 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 rootGroups = getRootGroups(state); + const groupUids = rootGroups.map(({ uid }) => uid); + const uid = state.termGroups.activeRootGroup; + 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)); } } }); @@ -106,15 +107,16 @@ 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 rootGroups = getRootGroups(state); + const groupUids = rootGroups.map(({ uid }) => uid); + 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)); } } }); @@ -127,13 +129,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 rootGroups = getRootGroups(state); + const groupUids = rootGroups.map(({ uid }) => uid); + const uid = state.termGroups.activeRootGroup; + if (uid === groupUids[i]) { console.log('ignoring same uid'); - } else if (null != sessionUids[i]) { - dispatch(setActiveSession(sessionUids[i])); + } else if (null != groupUids[i]) { + console.log('dispatching set group', groupUids[i]); + dispatch(setActiveGroup(groupUids[i])); } else { console.log('ignoring inexistent index', i); } diff --git a/lib/components/term-group.js b/lib/components/term-group.js new file mode 100644 index 000000000000..c0d6877056e6 --- /dev/null +++ b/lib/components/term-group.js @@ -0,0 +1,111 @@ +import React from 'react'; +import Term_ from './term'; +import Component from '../component'; +import { decorate, getTermProps } from '../utils/plugins'; +import { connect } from 'react-redux'; +import SplitPane from 'react-split-pane'; + +const Term = decorate(Term_, 'Term'); + +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]; + } + + /** + * Since react-split-pane doesn't support more than + * two child panes, we generate a tree of SplitPanes + * that each have two children. + * + * TODO: Should probably change to something else + * than react-split-pane or roll our own. + */ + renderSplit (groups) { + const [first, ...rest] = groups; + if (!rest.length) return first; + const percentage = Math.round(100 / groups.length); + const direction = this.props.termGroup.direction.toLowerCase(); + return + {first} + {this.renderSplit(rest)} + ; + } + + renderTerm (uid) { + const session = this.props.sessions[uid]; + const props = getTermProps(uid, this.props, { + 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, + 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 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 ; + }); + + return this.renderSplit(groups); + } +} + +const mapStateToProps = (state, ownProps) => ({ + childGroups: ownProps.termGroup.children.map(uid => + state.termGroups.termGroups[uid] + ) +}); + +const TermGroup = connect(mapStateToProps)(TermGroup_); + +export default TermGroup; diff --git a/lib/components/term.js b/lib/components/term.js index 9a69151deb3a..7fd8f90e692c 100644 --- a/lib/components/term.js +++ b/lib/components/term.js @@ -13,6 +13,7 @@ export default class Term extends Component { this.onWheel = this.onWheel.bind(this); this.onScrollEnter = this.onScrollEnter.bind(this); this.onScrollLeave = this.onScrollLeave.bind(this); + this.onFocus = this.onFocus.bind(this); props.ref_(this); } @@ -70,6 +71,11 @@ export default class Term extends Component { const iframeWindow = this.getTermDocument().defaultView; iframeWindow.addEventListener('wheel', this.onWheel); + + const screenNode = this.getScreenNode(); + // TODO: This doesn't work with htop + // (focus event is never emitted) + screenNode.addEventListener('focus', this.onFocus); } onWheel (e) { @@ -96,6 +102,13 @@ export default class Term extends Component { this.scrollMouseEnter = false; } + onFocus () { + // 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); @@ -149,6 +162,10 @@ export default class Term extends Component { this.term.selectAll(); } + getScreenNode () { + return this.term.scrollPort_.getScreenNode(); + } + getTermDocument () { return this.term.document_; } @@ -249,7 +266,9 @@ export default class Term extends Component { } template (css) { - return
+ return
{ this.props.customChildrenBefore }
{ this.props.url @@ -276,6 +295,7 @@ export default class Term extends Component { styles () { return { fit: { + display: 'block', width: '100%', height: '100%' }, diff --git a/lib/components/terms.js b/lib/components/terms.js index f7995fa9d6e1..7c09ab95d767 100644 --- a/lib/components/terms.js +++ b/lib/components/terms.js @@ -1,10 +1,9 @@ import React from 'react'; -import Term_ from './term'; import Component from '../component'; -import { last } from '../utils/array'; -import { decorate, getTermProps } from '../utils/plugins'; +import TermGroup_ from './term-group'; +import { decorate, getTermGroupProps } from '../utils/plugins'; -const Term = decorate(Term_, 'Term'); +const TermGroup = decorate(TermGroup_, 'TermGroup'); export default class Terms extends Component { @@ -21,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)) { - 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) { @@ -94,21 +58,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; } @@ -119,16 +68,15 @@ export default class Terms extends Component { template (css) { return
{ 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, + sessions: this.props.sessions, customCSS: this.props.customCSS, fontSize: this.props.fontSize, cursorColor: this.props.cursorColor, @@ -137,23 +85,24 @@ export default class Terms extends Component { 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 + copyOnSelect: this.props.copyOnSelect, + onActive: this.props.onActive, + onResize: this.props.onResize, + onTitle: this.props.onTitle, + onData: this.props.onData, + onURLAbort: this.props.onURLAbort }); + return
- +
; }) @@ -174,24 +123,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; } diff --git a/lib/constants/term-groups.js b/lib/constants/term-groups.js new file mode 100644 index 000000000000..dd2cf92be2b2 --- /dev/null +++ b/lib/constants/term-groups.js @@ -0,0 +1,6 @@ +export const TERM_GROUP_REQUEST = 'TERM_GROUP_REQUEST'; +export const TERM_GROUP_SPLIT = 'TERM_GROUP_SPLIT'; +export const DIRECTION = { + HORIZONTAL: 'HORIZONTAL', + VERTICAL: 'VERTICAL' +}; diff --git a/lib/containers/header.js b/lib/containers/header.js index a84c3ce58ed9..adb2f7c5c7d7 100644 --- a/lib/containers/header.js +++ b/lib/containers/header.js @@ -1,22 +1,23 @@ import Header from '../components/header'; import { closeTab, changeTab, maximize, unmaximize } from '../actions/header'; -import { values } from '../utils/object'; import { createSelector } from 'reselect'; 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 getActivityMarkers = ({ ui }) => ui.activityMarkers; const getTabs = createSelector( - [getSessions, getActiveUid, getActivityMarkers], - (sessions, activeUid, activityMarkers) => values(sessions).map((s) => { + [getSessions, getRootGroups, getActiveRootGroup, getActivityMarkers], + (sessions, rootGroups, activeRootGroup, activityMarkers) => rootGroups.map((t) => { + const session = sessions[t.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] }; }) ); @@ -26,7 +27,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, diff --git a/lib/containers/hyperterm.js b/lib/containers/hyperterm.js index 98a6b720f2a0..e23d77c3df2d 100644 --- a/lib/containers/hyperterm.js +++ b/lib/containers/hyperterm.js @@ -3,6 +3,7 @@ import HeaderContainer from './header'; import TermsContainer from './terms'; import NotificationsContainer from './notifications'; import Component from '../component'; +import splitPaneCSS from '../split-pane-styling'; import Mousetrap from 'mousetrap'; import * as uiActions from '../actions/ui'; import { connect } from '../utils/plugins'; @@ -85,7 +86,7 @@ class HyperTerm extends Component { template (css) { const { isMac, customCSS, borderColor } = this.props; - return
+ return
@@ -94,6 +95,7 @@ class HyperTerm extends Component {
+