diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..cffe8cde --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +save-exact=true diff --git a/app/.npmrc b/app/.npmrc new file mode 100644 index 00000000..cffe8cde --- /dev/null +++ b/app/.npmrc @@ -0,0 +1 @@ +save-exact=true diff --git a/app/index.js b/app/index.js index 5e9082c2..a4b3425c 100644 --- a/app/index.js +++ b/app/index.js @@ -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}) => { diff --git a/app/menu.js b/app/menu.js index 15c5a598..144a59e5 100644 --- a/app/menu.js +++ b/app/menu.js @@ -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' }, diff --git a/lib/actions/header.js b/lib/actions/header.js index 6b5f2c57..bfc05faf 100644 --- a/lib/actions/header.js +++ b/lib/actions/header.js @@ -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)); } }); }; diff --git a/lib/actions/sessions.js b/lib/actions/sessions.js index 2bfc2d15..9485b1ff 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, @@ -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}); + } + }); }; } diff --git a/lib/actions/term-groups.js b/lib/actions/term-groups.js new file mode 100644 index 00000000..d068816f --- /dev/null +++ b/lib/actions/term-groups.js @@ -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)); + } + }); + }; +} diff --git a/lib/actions/ui.js b/lib/actions/ui.js index 8e02a167..b2370828 100644 --- a/lib/actions/ui.js +++ b/lib/actions/ui.js @@ -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); } } }); diff --git a/lib/components/header.js b/lib/components/header.js index 00e7adeb..8daea89c 100644 --- a/lib/components/header.js +++ b/lib/components/header.js @@ -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; } diff --git a/lib/components/split-pane.js b/lib/components/split-pane.js new file mode 100644 index 00000000..0e48c271 --- /dev/null +++ b/lib/components/split-pane.js @@ -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 (
+ { + React.Children.map(children, (child, i) => { + const style = { + flexBasis: (sizes[i] * 100) + '%', + flexGrow: 0 + }; + return [ +
+ { child } +
, + i < children.length - 1 ? +
: + null + ]; + }) + } +
+
); + } + + 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