├── app ├── index.d.ts ├── static │ ├── icon.png │ └── icon96x96.png ├── notify.html ├── ext-modules.d.ts ├── tsconfig.json ├── utils │ ├── window-utils.ts │ ├── renderer-utils.ts │ ├── to-electron-background-color.ts │ ├── colors.ts │ ├── map-keys.ts │ └── cli-install.ts ├── config │ ├── windows.ts │ ├── init.ts │ ├── open.ts │ ├── paths.ts │ └── import.ts ├── menus │ ├── menus │ │ ├── plugins.ts │ │ ├── darwin.ts │ │ ├── view.ts │ │ ├── shell.ts │ │ ├── window.ts │ │ ├── help.ts │ │ └── edit.ts │ └── menu.ts ├── index.html ├── plugins │ ├── extensions.ts │ └── install.ts ├── package.json ├── notifications.ts ├── notify.ts ├── ui │ └── contextmenu.ts ├── auto-updater-linux.js ├── keymaps │ ├── win32.json │ ├── linux.json │ └── darwin.json ├── rpc.ts ├── system-context-menu.ts ├── updater.js ├── commands.ts └── config.ts ├── lib ├── rpc.ts ├── constants │ ├── index.ts │ ├── tabs.ts │ ├── config.ts │ ├── updater.ts │ ├── notifications.ts │ ├── term-groups.ts │ ├── sessions.ts │ └── ui.ts ├── store │ ├── configure-store.ts │ ├── write-middleware.ts │ ├── configure-store.prod.ts │ └── configure-store.dev.ts ├── actions │ ├── index.ts │ ├── config.ts │ ├── notifications.ts │ ├── updater.ts │ ├── header.ts │ ├── sessions.ts │ └── term-groups.ts ├── terms.ts ├── ext-modules.d.ts ├── selectors.ts ├── utils │ ├── term-groups.ts │ ├── notify.ts │ ├── object.ts │ ├── effects.ts │ ├── config.ts │ ├── paste.ts │ ├── file.ts │ └── rpc.ts ├── reducers │ ├── index.ts │ └── sessions.ts ├── command-registry.ts ├── components │ ├── searchBox.js │ ├── tabs.js │ ├── notification.js │ ├── style-sheet.js │ ├── notifications.js │ ├── term-group.js │ └── tab.js ├── containers │ ├── notifications.ts │ ├── header.ts │ ├── terms.ts │ └── hyper.tsx └── hyper.d.ts ├── .gitattributes ├── .huskyrc.json ├── .eslintignore ├── .yarnrc ├── test ├── testUtils │ └── is-hex-color.ts ├── unit │ ├── to-electron-background-color.test.ts │ ├── cli-api.test.ts │ └── window-utils.test.ts └── index.ts ├── .editorconfig ├── ava.config.js ├── .gitignore ├── tsconfig.json ├── babel.config.json ├── release.js ├── tsconfig.base.json ├── .github ├── pull_request_template.md ├── issue_template.md └── workflows │ └── nodejs.yml ├── .vscode └── launch.json ├── appveyor.yml ├── .travis.yml ├── LICENSE ├── electron-builder.json ├── .circleci └── config.yml ├── .eslintrc.json ├── assets └── icons.svg ├── webpack.config.ts ├── README.md ├── package.json └── cli └── api.ts /app/index.d.ts: -------------------------------------------------------------------------------- 1 | // Dummy file, required by tsc 2 | -------------------------------------------------------------------------------- /lib/rpc.ts: -------------------------------------------------------------------------------- 1 | import RPC from './utils/rpc'; 2 | 3 | export default new RPC(); 4 | -------------------------------------------------------------------------------- /app/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iheanyi/hyper/canary/app/static/icon.png -------------------------------------------------------------------------------- /app/static/icon96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iheanyi/hyper/canary/app/static/icon96x96.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | *.ts text eol=lf 4 | *.tsx text eol=lf 5 | bin/* linguist-vendored 6 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/huskyrc", 3 | "hooks": { 4 | "pre-push": "yarn test" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | app/renderer 3 | app/static 4 | app/bin 5 | app/dist 6 | app/node_modules 7 | assets 8 | website 9 | bin 10 | dist 11 | target -------------------------------------------------------------------------------- /lib/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const INIT = 'INIT'; 2 | 3 | export interface InitAction { 4 | type: typeof INIT; 5 | } 6 | 7 | export type InitActions = InitAction; 8 | -------------------------------------------------------------------------------- /app/notify.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | child-concurrency "1" 6 | lastUpdateCheck 1570388773781 7 | save-exact true 8 | -------------------------------------------------------------------------------- /test/testUtils/is-hex-color.ts: -------------------------------------------------------------------------------- 1 | function isHexColor(color: string) { 2 | return /(^#[0-9A-F]{6,8}$)|(^#[0-9A-F]{3}$)/i.test(color); // https://regex101.com/ 3 | } 4 | 5 | export {isHexColor}; 6 | -------------------------------------------------------------------------------- /app/ext-modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'git-describe' { 2 | export function gitDescribe(...args: any[]): void; 3 | } 4 | 5 | declare module 'default-shell' { 6 | const val: string; 7 | export default val; 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | files: ['test/unit/*'], 3 | babel: { 4 | compileEnhancements: false, 5 | compileAsTests: ['**/testUtils/**/*'] 6 | }, 7 | extensions: ['ts'], 8 | require: ['ts-node/register/transpile-only'] 9 | }; 10 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "declarationDir": "../dist/tmp/appdts/", 5 | "outDir": "../target/" 6 | }, 7 | "include": [ 8 | "./**/*", 9 | "./package.json" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist 3 | app/renderer 4 | target 5 | bin/cli.* 6 | 7 | # dependencies 8 | node_modules 9 | 10 | # logs 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # optional dev config file and plugins directory 15 | .hyper.js 16 | .hyper_plugins 17 | 18 | .DS_Store 19 | .vscode/settings.json 20 | -------------------------------------------------------------------------------- /lib/constants/tabs.ts: -------------------------------------------------------------------------------- 1 | export const CLOSE_TAB = 'CLOSE_TAB'; 2 | export const CHANGE_TAB = 'CHANGE_TAB'; 3 | 4 | export interface CloseTabAction { 5 | type: typeof CLOSE_TAB; 6 | } 7 | export interface ChangeTabAction { 8 | type: typeof CHANGE_TAB; 9 | } 10 | 11 | export type TabActions = CloseTabAction | ChangeTabAction; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "./dist/tmp/root/" 5 | }, 6 | "include": [ 7 | "./app/", 8 | "./lib/", 9 | "./test/", 10 | "./cli/" 11 | ], 12 | "references": [ 13 | { 14 | "path": "./app/tsconfig.json" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /lib/store/configure-store.ts: -------------------------------------------------------------------------------- 1 | import configureStoreForProduction from './configure-store.prod'; 2 | import configureStoreForDevelopment from './configure-store.dev'; 3 | 4 | export default () => { 5 | if (process.env.NODE_ENV === 'production') { 6 | return configureStoreForProduction(); 7 | } 8 | 9 | return configureStoreForDevelopment(); 10 | }; 11 | -------------------------------------------------------------------------------- /lib/actions/index.ts: -------------------------------------------------------------------------------- 1 | import rpc from '../rpc'; 2 | import {INIT} from '../constants'; 3 | import {HyperDispatch} from '../hyper'; 4 | 5 | export default function init() { 6 | return (dispatch: HyperDispatch) => { 7 | dispatch({ 8 | type: INIT, 9 | effect: () => { 10 | rpc.emit('init', null); 11 | } 12 | }); 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /lib/terms.ts: -------------------------------------------------------------------------------- 1 | import Term from './components/term'; 2 | 3 | // react Term components add themselves 4 | // to this object upon mounting / unmounting 5 | // this is to allow imperative access to the 6 | // term API, which is a performance 7 | // optimization for the most common action 8 | // within the system 9 | 10 | const terms: Record = {}; 11 | export default terms; 12 | -------------------------------------------------------------------------------- /lib/ext-modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'php-escape-shell' { 2 | // eslint-disable-next-line @typescript-eslint/camelcase 3 | export function php_escapeshellcmd(path: string): string; 4 | } 5 | 6 | declare module 'parse-url' { 7 | export default function(...args: any[]): any; 8 | } 9 | 10 | declare module 'react-deep-force-update' { 11 | export default function(...args: any[]): any; 12 | } 13 | -------------------------------------------------------------------------------- /lib/selectors.ts: -------------------------------------------------------------------------------- 1 | import {createSelector} from 'reselect'; 2 | import {HyperState} from './hyper'; 3 | 4 | const getTermGroups = ({termGroups}: Pick) => termGroups.termGroups; 5 | export const getRootGroups = createSelector(getTermGroups, termGroups => 6 | Object.keys(termGroups) 7 | .map(uid => termGroups[uid]) 8 | .filter(({parentUid}) => !parentUid) 9 | ); 10 | -------------------------------------------------------------------------------- /lib/utils/term-groups.ts: -------------------------------------------------------------------------------- 1 | import {ITermState} from '../hyper'; 2 | import {Immutable} from 'seamless-immutable'; 3 | 4 | export default function findBySession(termGroupState: Immutable, sessionUid: string) { 5 | const {termGroups} = termGroupState; 6 | return Object.keys(termGroups) 7 | .map(uid => termGroups[uid]) 8 | .find(group => group.sessionUid === sessionUid); 9 | } 10 | -------------------------------------------------------------------------------- /app/utils/window-utils.ts: -------------------------------------------------------------------------------- 1 | import electron from 'electron'; 2 | 3 | export function positionIsValid(position: [number, number]) { 4 | const displays = electron.screen.getAllDisplays(); 5 | const [x, y] = position; 6 | 7 | return displays.some(({workArea}) => { 8 | return x >= workArea.x && x <= workArea.x + workArea.width && y >= workArea.y && y <= workArea.y + workArea.height; 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /lib/utils/notify.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-new:0 */ 2 | export default function notify(title: string, body: string, details: Record = {}) { 3 | //eslint-disable-next-line no-console 4 | console.log(`[Notification] ${title}: ${body}`); 5 | if (details.error) { 6 | //eslint-disable-next-line no-console 7 | console.error(details.error); 8 | } 9 | new Notification(title, {body}); 10 | } 11 | -------------------------------------------------------------------------------- /app/utils/renderer-utils.ts: -------------------------------------------------------------------------------- 1 | const rendererTypes: Record = {}; 2 | 3 | function getRendererTypes() { 4 | return rendererTypes; 5 | } 6 | 7 | function setRendererType(uid: string, type: string) { 8 | rendererTypes[uid] = type; 9 | } 10 | 11 | function unsetRendererType(uid: string) { 12 | delete rendererTypes[uid]; 13 | } 14 | 15 | export {getRendererTypes, setRendererType, unsetRendererType}; 16 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/react", 4 | "@babel/typescript" 5 | ], 6 | "plugins": [ 7 | [ 8 | "styled-jsx/babel", 9 | { 10 | "vendorPrefixes": false 11 | } 12 | ], 13 | "@babel/plugin-proposal-numeric-separator", 14 | "@babel/proposal-class-properties", 15 | "@babel/proposal-object-rest-spread", 16 | "@babel/plugin-proposal-optional-chaining" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /lib/constants/config.ts: -------------------------------------------------------------------------------- 1 | export const CONFIG_LOAD = 'CONFIG_LOAD'; 2 | export const CONFIG_RELOAD = 'CONFIG_RELOAD'; 3 | 4 | export interface ConfigLoadAction { 5 | type: typeof CONFIG_LOAD; 6 | config: any; 7 | now?: number; 8 | } 9 | 10 | export interface ConfigReloadAction { 11 | type: typeof CONFIG_RELOAD; 12 | config: any; 13 | now: number; 14 | } 15 | 16 | export type ConfigActions = ConfigLoadAction | ConfigReloadAction; 17 | -------------------------------------------------------------------------------- /lib/actions/config.ts: -------------------------------------------------------------------------------- 1 | import {CONFIG_LOAD, CONFIG_RELOAD} from '../constants/config'; 2 | import {HyperActions} from '../hyper'; 3 | 4 | export function loadConfig(config: any): HyperActions { 5 | return { 6 | type: CONFIG_LOAD, 7 | config 8 | }; 9 | } 10 | 11 | export function reloadConfig(config: any): HyperActions { 12 | const now = Date.now(); 13 | return { 14 | type: CONFIG_RELOAD, 15 | config, 16 | now 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /lib/utils/object.ts: -------------------------------------------------------------------------------- 1 | const valsCache = new WeakMap(); 2 | 3 | export function values(imm: Record) { 4 | if (!valsCache.has(imm)) { 5 | valsCache.set(imm, Object.values(imm)); 6 | } 7 | return valsCache.get(imm); 8 | } 9 | 10 | const keysCache = new WeakMap(); 11 | export function keys(imm: Record) { 12 | if (!keysCache.has(imm)) { 13 | keysCache.set(imm, Object.keys(imm)); 14 | } 15 | return keysCache.get(imm); 16 | } 17 | -------------------------------------------------------------------------------- /lib/constants/updater.ts: -------------------------------------------------------------------------------- 1 | export const UPDATE_INSTALL = 'UPDATE_INSTALL'; 2 | export const UPDATE_AVAILABLE = 'UPDATE_AVAILABLE'; 3 | 4 | export interface UpdateInstallAction { 5 | type: typeof UPDATE_INSTALL; 6 | } 7 | export interface UpdateAvailableAction { 8 | type: typeof UPDATE_AVAILABLE; 9 | version: string; 10 | notes: string | null; 11 | releaseUrl: string; 12 | canInstall: boolean; 13 | } 14 | 15 | export type UpdateActions = UpdateInstallAction | UpdateAvailableAction; 16 | -------------------------------------------------------------------------------- /release.js: -------------------------------------------------------------------------------- 1 | // Packages 2 | const {prompt} = require('inquirer'); 3 | 4 | module.exports = async markdown => { 5 | const answers = await prompt([ 6 | { 7 | name: 'intro', 8 | message: 'One-Line Release Summary' 9 | } 10 | ]); 11 | 12 | const {intro} = answers; 13 | 14 | if (intro === '') { 15 | //eslint-disable-next-line no-console 16 | console.error('Please specify a release summary!'); 17 | 18 | process.exit(1); 19 | } 20 | 21 | return `${intro}\n\n${markdown}`; 22 | }; 23 | -------------------------------------------------------------------------------- /lib/constants/notifications.ts: -------------------------------------------------------------------------------- 1 | export const NOTIFICATION_MESSAGE = 'NOTIFICATION_MESSAGE'; 2 | export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS'; 3 | 4 | export interface NotificationMessageAction { 5 | type: typeof NOTIFICATION_MESSAGE; 6 | text: string; 7 | url: string | null; 8 | dismissable: boolean; 9 | } 10 | export interface NotificationDismissAction { 11 | type: typeof NOTIFICATION_DISMISS; 12 | id: string; 13 | } 14 | 15 | export type NotificationActions = NotificationMessageAction | NotificationDismissAction; 16 | -------------------------------------------------------------------------------- /lib/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | import ui, {IUiReducer} from './ui'; 3 | import sessions, {ISessionReducer} from './sessions'; 4 | import termGroups, {ITermGroupReducer} from './term-groups'; 5 | import {HyperActions} from '../hyper'; 6 | 7 | export default combineReducers< 8 | { 9 | ui: ReturnType; 10 | sessions: ReturnType; 11 | termGroups: ReturnType; 12 | }, 13 | HyperActions 14 | >({ 15 | ui, 16 | sessions, 17 | termGroups 18 | }); 19 | -------------------------------------------------------------------------------- /lib/store/write-middleware.ts: -------------------------------------------------------------------------------- 1 | import terms from '../terms'; 2 | import {Middleware} from 'redux'; 3 | 4 | // the only side effect we perform from middleware 5 | // is to write to the react term instance directly 6 | // to avoid a performance hit 7 | const writeMiddleware: Middleware = () => next => action => { 8 | if (action.type === 'SESSION_PTY_DATA') { 9 | const term = terms[action.uid]; 10 | if (term) { 11 | term.term.write(action.data); 12 | } 13 | } 14 | next(action); 15 | }; 16 | 17 | export default writeMiddleware; 18 | -------------------------------------------------------------------------------- /lib/actions/notifications.ts: -------------------------------------------------------------------------------- 1 | import {NOTIFICATION_MESSAGE, NOTIFICATION_DISMISS} from '../constants/notifications'; 2 | import {HyperActions} from '../hyper'; 3 | 4 | export function dismissNotification(id: string): HyperActions { 5 | return { 6 | type: NOTIFICATION_DISMISS, 7 | id 8 | }; 9 | } 10 | 11 | export function addNotificationMessage(text: string, url: string | null = null, dismissable = true): HyperActions { 12 | return { 13 | type: NOTIFICATION_MESSAGE, 14 | text, 15 | url, 16 | dismissable 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": false, 5 | "composite": true, 6 | "downlevelIteration": true, 7 | "esModuleInterop": true, 8 | "jsx": "react", 9 | "lib": [ 10 | "dom", 11 | "es2017" 12 | ], 13 | "module": "commonjs", 14 | "moduleResolution": "node", 15 | "preserveConstEnums": true, 16 | "removeComments": false, 17 | "resolveJsonModule": true, 18 | "sourceMap": true, 19 | "strict": true, 20 | "target": "es2018" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/store/configure-store.prod.ts: -------------------------------------------------------------------------------- 1 | import {createStore, applyMiddleware} from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import rootReducer from '../reducers/index'; 4 | import effects from '../utils/effects'; 5 | import * as plugins from '../utils/plugins'; 6 | import writeMiddleware from './write-middleware'; 7 | import {HyperState, HyperThunkDispatch} from '../hyper'; 8 | 9 | export default () => 10 | createStore( 11 | rootReducer, 12 | applyMiddleware(thunk, plugins.middleware, thunk, writeMiddleware, effects) 13 | ); 14 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /app/config/windows.ts: -------------------------------------------------------------------------------- 1 | import Config from 'electron-store'; 2 | import {BrowserWindow} from 'electron'; 3 | 4 | const defaults = { 5 | windowPosition: [50, 50], 6 | windowSize: [540, 380] 7 | }; 8 | 9 | // local storage 10 | const cfg = new Config({defaults}); 11 | 12 | export default { 13 | defaults, 14 | get() { 15 | const position = cfg.get('windowPosition'); 16 | const size = cfg.get('windowSize'); 17 | return {position, size}; 18 | }, 19 | recordState(win: BrowserWindow) { 20 | cfg.set('windowPosition', win.getPosition()); 21 | cfg.set('windowSize', win.getSize()); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /lib/utils/effects.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple redux middleware that executes 3 | * the `effect` field if provided in an action 4 | * since this is preceded by the `plugins` 5 | * middleware. It allows authors to interrupt, 6 | * defer or add to existing side effects at will 7 | * as the result of an action being triggered. 8 | */ 9 | import {Middleware} from 'redux'; 10 | const effectsMiddleware: Middleware = () => next => action => { 11 | const ret = next(action); 12 | if (action.effect) { 13 | action.effect(); 14 | delete action.effect; 15 | } 16 | return ret; 17 | }; 18 | export default effectsMiddleware; 19 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Launch Hyper", 8 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", 9 | "program": "${workspaceRoot}/target/index.js", 10 | "protocol": "inspector" 11 | }, 12 | { 13 | "type": "node", 14 | "request": "launch", 15 | "name": "cli", 16 | "runtimeExecutable": "node", 17 | "program": "${workspaceRoot}/bin/cli.js", 18 | "args": ["--help"], 19 | "protocol": "inspector" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /lib/actions/updater.ts: -------------------------------------------------------------------------------- 1 | import {UPDATE_INSTALL, UPDATE_AVAILABLE} from '../constants/updater'; 2 | import rpc from '../rpc'; 3 | import {HyperActions} from '../hyper'; 4 | 5 | export function installUpdate(): HyperActions { 6 | return { 7 | type: UPDATE_INSTALL, 8 | effect: () => { 9 | rpc.emit('quit and install', null); 10 | } 11 | }; 12 | } 13 | 14 | export function updateAvailable(version: string, notes: string, releaseUrl: string, canInstall: boolean): HyperActions { 15 | return { 16 | type: UPDATE_AVAILABLE, 17 | version, 18 | notes, 19 | releaseUrl, 20 | canInstall 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /app/utils/to-electron-background-color.ts: -------------------------------------------------------------------------------- 1 | // Packages 2 | import Color from 'color'; 3 | 4 | // returns a background color that's in hex 5 | // format including the alpha channel (e.g.: `#00000050`) 6 | // input can be any css value (rgb, hsl, string…) 7 | export default (bgColor: string) => { 8 | const color = Color(bgColor); 9 | 10 | if (color.alpha() === 1) { 11 | return color.hex().toString(); 12 | } 13 | 14 | // http://stackoverflow.com/a/11019879/1202488 15 | const alphaHex = Math.round(color.alpha() * 255).toString(16); 16 | return `#${alphaHex}${color 17 | .hex() 18 | .toString() 19 | .substr(1)}`; 20 | }; 21 | -------------------------------------------------------------------------------- /lib/utils/config.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer, remote} from 'electron'; 2 | // TODO: Should be updates to new async API https://medium.com/@nornagon/electrons-remote-module-considered-harmful-70d69500f31 3 | 4 | const plugins = remote.require('./plugins') as typeof import('../../app/plugins'); 5 | 6 | export function getConfig() { 7 | return plugins.getDecoratedConfig(); 8 | } 9 | 10 | export function subscribe(fn: (event: Electron.IpcRendererEvent, ...args: any[]) => void) { 11 | ipcRenderer.on('config change', fn); 12 | ipcRenderer.on('plugins change', fn); 13 | return () => { 14 | ipcRenderer.removeListener('config change', fn); 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/sindresorhus/appveyor-node/blob/master/appveyor.yml 2 | 3 | environment: 4 | matrix: 5 | - platform: x64 6 | 7 | image: Visual Studio 2019 8 | 9 | install: 10 | - ps: Install-Product node 12 x64 11 | - set CI=true 12 | - yarn 13 | 14 | build: off 15 | 16 | matrix: 17 | fast_finish: true 18 | 19 | shallow_clone: true 20 | 21 | test_script: 22 | - node --version 23 | - yarn --version 24 | - yarn run test 25 | 26 | on_success: 27 | - IF %APPVEYOR_REPO_BRANCH%==canary cp build\canary.ico build\icon.ico 28 | - yarn run dist 29 | - ps: Get-ChildItem .\dist\squirrel-windows\*.exe | % { Push-AppveyorArtifact $_.FullName } 30 | -------------------------------------------------------------------------------- /lib/store/configure-store.dev.ts: -------------------------------------------------------------------------------- 1 | import {createStore, applyMiddleware} from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import rootReducer from '../reducers/index'; 4 | import effects from '../utils/effects'; 5 | import * as plugins from '../utils/plugins'; 6 | import writeMiddleware from './write-middleware'; 7 | import {composeWithDevTools} from 'redux-devtools-extension'; 8 | import {HyperState, HyperThunkDispatch} from '../hyper'; 9 | 10 | export default () => { 11 | const enhancer = composeWithDevTools( 12 | applyMiddleware(thunk, plugins.middleware, thunk, writeMiddleware, effects) 13 | ); 14 | 15 | return createStore(rootReducer, enhancer); 16 | }; 17 | -------------------------------------------------------------------------------- /app/utils/colors.ts: -------------------------------------------------------------------------------- 1 | const colorList = [ 2 | 'black', 3 | 'red', 4 | 'green', 5 | 'yellow', 6 | 'blue', 7 | 'magenta', 8 | 'cyan', 9 | 'white', 10 | 'lightBlack', 11 | 'lightRed', 12 | 'lightGreen', 13 | 'lightYellow', 14 | 'lightBlue', 15 | 'lightMagenta', 16 | 'lightCyan', 17 | 'lightWhite', 18 | 'colorCubes', 19 | 'grayscale' 20 | ]; 21 | 22 | export const getColorMap: { 23 | (colors: T): T extends (infer U)[] ? {[k: string]: U} : T; 24 | } = colors => { 25 | if (!Array.isArray(colors)) { 26 | return colors; 27 | } 28 | return colors.reduce((result, color, index) => { 29 | if (index < colorList.length) { 30 | result[colorList[index]] = color; 31 | } 32 | return result; 33 | }, {}); 34 | }; 35 | -------------------------------------------------------------------------------- /app/menus/menus/plugins.ts: -------------------------------------------------------------------------------- 1 | import {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 2 | 3 | export default ( 4 | commands: Record, 5 | execCommand: (command: string, focusedWindow?: BrowserWindow) => void 6 | ): MenuItemConstructorOptions => { 7 | return { 8 | label: 'Plugins', 9 | submenu: [ 10 | { 11 | label: 'Update', 12 | accelerator: commands['plugins:update'], 13 | click() { 14 | execCommand('plugins:update'); 15 | } 16 | }, 17 | { 18 | label: 'Install Hyper CLI command in PATH', 19 | click() { 20 | execCommand('cli:install'); 21 | } 22 | }, 23 | { 24 | type: 'separator' 25 | } 26 | ] 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /lib/utils/paste.ts: -------------------------------------------------------------------------------- 1 | import {clipboard} from 'electron'; 2 | import plist from 'plist'; 3 | 4 | const getPath = (platform: string) => { 5 | switch (platform) { 6 | case 'darwin': { 7 | if (clipboard.has('NSFilenamesPboardType')) { 8 | // Parse plist file containing the path list of copied files 9 | const list = plist.parse(clipboard.read('NSFilenamesPboardType')) as plist.PlistArray; 10 | return "'" + list.join("' '") + "'"; 11 | } else { 12 | return null; 13 | } 14 | } 15 | case 'win32': { 16 | const filepath = clipboard.read('FileNameW'); 17 | return filepath.replace(new RegExp(String.fromCharCode(0), 'g'), ''); 18 | } 19 | // Linux already pastes full path 20 | default: 21 | return null; 22 | } 23 | }; 24 | 25 | export default function processClipboard() { 26 | return getPath(process.platform); 27 | } 28 | -------------------------------------------------------------------------------- /lib/constants/term-groups.ts: -------------------------------------------------------------------------------- 1 | export const TERM_GROUP_REQUEST = 'TERM_GROUP_REQUEST'; 2 | export const TERM_GROUP_EXIT = 'TERM_GROUP_EXIT'; 3 | export const TERM_GROUP_RESIZE = 'TERM_GROUP_RESIZE'; 4 | export const TERM_GROUP_EXIT_ACTIVE = 'TERM_GROUP_EXIT_ACTIVE'; 5 | export const DIRECTION = { 6 | HORIZONTAL: 'HORIZONTAL', 7 | VERTICAL: 'VERTICAL' 8 | }; 9 | 10 | export interface TermGroupRequestAction { 11 | type: typeof TERM_GROUP_REQUEST; 12 | } 13 | export interface TermGroupExitAction { 14 | type: typeof TERM_GROUP_EXIT; 15 | uid: string; 16 | } 17 | export interface TermGroupResizeAction { 18 | type: typeof TERM_GROUP_RESIZE; 19 | uid: string; 20 | sizes: number[]; 21 | } 22 | export interface TermGroupExitActiveAction { 23 | type: typeof TERM_GROUP_EXIT_ACTIVE; 24 | } 25 | 26 | export type TermGroupActions = 27 | | TermGroupRequestAction 28 | | TermGroupExitAction 29 | | TermGroupResizeAction 30 | | TermGroupExitActiveAction; 31 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hyper 5 | 6 | 7 | 8 | 9 | 10 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /test/unit/to-electron-background-color.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import toElectronBackgroundColor from '../../app/utils/to-electron-background-color'; 3 | import {isHexColor} from '../testUtils/is-hex-color'; 4 | 5 | test('toElectronBackgroundColor', t => { 6 | t.false(false); 7 | }); 8 | 9 | test(`returns a color that's in hex`, t => { 10 | const hexColor = '#BADA55'; 11 | const rgbColor = 'rgb(0,0,0)'; 12 | const rgbaColor = 'rgb(0,0,0, 55)'; 13 | const hslColor = 'hsl(15, 100%, 50%)'; 14 | const hslaColor = 'hsl(15, 100%, 50%, 1)'; 15 | const colorKeyword = 'pink'; 16 | 17 | t.true(isHexColor(toElectronBackgroundColor(hexColor))); 18 | 19 | t.true(isHexColor(toElectronBackgroundColor(rgbColor))); 20 | 21 | t.true(isHexColor(toElectronBackgroundColor(rgbaColor))); 22 | 23 | t.true(isHexColor(toElectronBackgroundColor(hslColor))); 24 | 25 | t.true(isHexColor(toElectronBackgroundColor(hslaColor))); 26 | 27 | t.true(isHexColor(toElectronBackgroundColor(colorKeyword))); 28 | }); 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: xenial 3 | 4 | language: node_js 5 | 6 | matrix: 7 | include: 8 | - os: linux 9 | node_js: 12 10 | env: CC=clang CXX=clang++ npm_config_clang=1 11 | compiler: clang 12 | 13 | addons: 14 | apt: 15 | packages: 16 | - gcc-multilib 17 | - g++-multilib 18 | - libgnome-keyring-dev 19 | - icnsutils 20 | - graphicsmagick 21 | - xz-utils 22 | - rpm 23 | - bsdtar 24 | - snapd 25 | 26 | before_install: 27 | - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sudo snap install snapcraft --classic; fi 28 | - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then export DISPLAY=:99.0; sh -e /etc/init.d/xvfb start; sleep 3; fi 29 | 30 | cache: yarn 31 | 32 | install: 33 | - yarn 34 | 35 | after_success: 36 | - (git branch --contains $TRAVIS_COMMIT | grep canary > /dev/null || [[ "$TRAVIS_BRANCH" == "canary" ]] ) && (cd build; cp canary.icns icon.icns; cp canary.ico icon.ico) 37 | - yarn run dist 38 | 39 | branches: 40 | except: 41 | - "/^v\\d+\\.\\d+\\.\\d+$/" 42 | -------------------------------------------------------------------------------- /app/plugins/extensions.ts: -------------------------------------------------------------------------------- 1 | export const availableExtensions = new Set([ 2 | 'onApp', 3 | 'onWindowClass', 4 | 'decorateWindowClass', 5 | 'onWindow', 6 | 'onRendererWindow', 7 | 'onUnload', 8 | 'decorateSessionClass', 9 | 'decorateSessionOptions', 10 | 'middleware', 11 | 'reduceUI', 12 | 'reduceSessions', 13 | 'reduceTermGroups', 14 | 'decorateBrowserOptions', 15 | 'decorateMenu', 16 | 'decorateTerm', 17 | 'decorateHyper', 18 | 'decorateHyperTerm', // for backwards compatibility with hyperterm 19 | 'decorateHeader', 20 | 'decorateTerms', 21 | 'decorateTab', 22 | 'decorateNotification', 23 | 'decorateNotifications', 24 | 'decorateTabs', 25 | 'decorateConfig', 26 | 'decorateKeymaps', 27 | 'decorateEnv', 28 | 'decorateTermGroup', 29 | 'decorateSplitPane', 30 | 'getTermProps', 31 | 'getTabProps', 32 | 'getTabsProps', 33 | 'getTermGroupProps', 34 | 'mapHyperTermState', 35 | 'mapTermsState', 36 | 'mapHeaderState', 37 | 'mapNotificationsState', 38 | 'mapHyperTermDispatch', 39 | 'mapTermsDispatch', 40 | 'mapHeaderDispatch', 41 | 'mapNotificationsDispatch' 42 | ]); 43 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyper", 3 | "productName": "Hyper", 4 | "description": "A terminal built on web technologies", 5 | "version": "3.1.0-canary.3", 6 | "license": "MIT", 7 | "author": { 8 | "name": "ZEIT, Inc.", 9 | "email": "team@zeit.co" 10 | }, 11 | "repository": "zeit/hyper", 12 | "dependencies": { 13 | "async-retry": "1.3.1", 14 | "color": "3.1.2", 15 | "convert-css-color-name-to-hex": "0.1.1", 16 | "default-shell": "1.0.1", 17 | "electron-store": "5.1.0", 18 | "electron-fetch": "1.4.0", 19 | "electron-is-dev": "1.1.0", 20 | "electron-squirrel-startup": "1.0.0", 21 | "file-uri-to-path": "2.0.0", 22 | "fs-extra": "8.1.0", 23 | "git-describe": "4.0.4", 24 | "lodash": "4.17.15", 25 | "mkdirp": "1.0.3", 26 | "ms": "2.1.2", 27 | "node-pty": "0.9.0", 28 | "os-locale": "4.0.0", 29 | "parse-url": "5.0.1", 30 | "pify": "4.0.1", 31 | "queue": "6.0.1", 32 | "react": "16.12.0", 33 | "react-dom": "16.12.0", 34 | "semver": "7.1.2", 35 | "shell-env": "3.0.0", 36 | "uuid": "3.4.0", 37 | "winreg": "1.2.4" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2018 ZEIT, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | // Native 2 | import path from 'path'; 3 | 4 | // Packages 5 | import test from 'ava'; 6 | import {Application} from 'spectron'; 7 | 8 | let app: Application; 9 | 10 | test.before(async () => { 11 | let pathToBinary; 12 | 13 | switch (process.platform) { 14 | case 'linux': 15 | pathToBinary = path.join(__dirname, '../dist/linux-unpacked/hyper'); 16 | break; 17 | 18 | case 'darwin': 19 | pathToBinary = path.join(__dirname, '../dist/mac/Hyper.app/Contents/MacOS/Hyper'); 20 | break; 21 | 22 | case 'win32': 23 | pathToBinary = path.join(__dirname, '../dist/win-unpacked/Hyper.exe'); 24 | break; 25 | 26 | default: 27 | throw new Error('Path to the built binary needs to be defined for this platform in test/index.js'); 28 | } 29 | 30 | app = new Application({ 31 | path: pathToBinary 32 | }); 33 | 34 | await app.start(); 35 | }); 36 | 37 | test.after(async () => { 38 | await app.stop(); 39 | }); 40 | 41 | test('see if dev tools are open', async t => { 42 | await app.client.waitUntilWindowLoaded(); 43 | t.false(await app.webContents.isDevToolsOpened()); 44 | }); 45 | -------------------------------------------------------------------------------- /test/unit/cli-api.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | const proxyquire = require('proxyquire').noCallThru(); 3 | 4 | test('existsOnNpm() builds the url for non-scoped packages', t => { 5 | let getUrl: string; 6 | const {existsOnNpm} = proxyquire('../../cli/api', { 7 | got: { 8 | get(url: string) { 9 | getUrl = url; 10 | return Promise.resolve({ 11 | body: { 12 | versions: [] 13 | } 14 | }); 15 | } 16 | }, 17 | 'registry-url': () => 'https://registry.npmjs.org/' 18 | }); 19 | 20 | return existsOnNpm('pkg').then(() => { 21 | t.is(getUrl, 'https://registry.npmjs.org/pkg'); 22 | }); 23 | }); 24 | 25 | test('existsOnNpm() builds the url for scoped packages', t => { 26 | let getUrl: string; 27 | const {existsOnNpm} = proxyquire('../../cli/api', { 28 | got: { 29 | get(url: string) { 30 | getUrl = url; 31 | return Promise.resolve({ 32 | body: { 33 | versions: [] 34 | } 35 | }); 36 | } 37 | }, 38 | 'registry-url': () => 'https://registry.npmjs.org/' 39 | }); 40 | 41 | return existsOnNpm('@scope/pkg').then(() => { 42 | t.is(getUrl, 'https://registry.npmjs.org/@scope%2fpkg'); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /lib/utils/file.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Based on https://github.com/kevva/executable 3 | * Since this module doesn't expose the function to check stat mode only, 4 | * his logic is pasted here. 5 | * 6 | * Opened an issue and a pull request about it, 7 | * to maybe switch to module in the future: 8 | * 9 | * Issue: https://github.com/kevva/executable/issues/9 10 | * PR: https://github.com/kevva/executable/pull/10 11 | */ 12 | 13 | import fs, {Stats} from 'fs'; 14 | 15 | export function isExecutable(fileStat: Stats): boolean { 16 | if (process.platform === 'win32') { 17 | return true; 18 | } 19 | 20 | return Boolean(fileStat.mode & 0o0001 || fileStat.mode & 0o0010 || fileStat.mode & 0o0100); 21 | } 22 | 23 | export function getBase64FileData(filePath: string): Promise { 24 | return new Promise((resolve): void => { 25 | return fs.readFile(filePath, (err, data) => { 26 | if (err) { 27 | // Gracefully fail with a warning 28 | //eslint-disable-next-line no-console 29 | console.warn('There was an error reading the file at the local location:', err); 30 | return resolve(null); 31 | } 32 | 33 | const base64Data = Buffer.from(data).toString('base64'); 34 | return resolve(base64Data); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /app/notifications.ts: -------------------------------------------------------------------------------- 1 | import ms from 'ms'; 2 | import fetch from 'electron-fetch'; 3 | import {version} from './package.json'; 4 | import {BrowserWindow} from 'electron'; 5 | 6 | const NEWS_URL = 'https://hyper-news.now.sh'; 7 | 8 | export default function fetchNotifications(win: BrowserWindow) { 9 | const {rpc} = win; 10 | const retry = (err?: any) => { 11 | setTimeout(() => fetchNotifications(win), ms('30m')); 12 | if (err) { 13 | //eslint-disable-next-line no-console 14 | console.error('Notification messages fetch error', err.stack); 15 | } 16 | }; 17 | //eslint-disable-next-line no-console 18 | console.log('Checking for notification messages'); 19 | fetch(NEWS_URL, { 20 | headers: { 21 | 'X-Hyper-Version': version, 22 | 'X-Hyper-Platform': process.platform 23 | } 24 | }) 25 | .then(res => res.json()) 26 | .then(data => { 27 | const {message} = data || {}; 28 | if (typeof message !== 'object' && message !== '') { 29 | throw new Error('Bad response'); 30 | } 31 | if (message === '') { 32 | //eslint-disable-next-line no-console 33 | console.log('No matching notification messages'); 34 | } else { 35 | rpc.emit('add notification', message); 36 | } 37 | 38 | retry(); 39 | }) 40 | .catch(retry); 41 | } 42 | -------------------------------------------------------------------------------- /app/notify.ts: -------------------------------------------------------------------------------- 1 | import {resolve} from 'path'; 2 | import {app, BrowserWindow} from 'electron'; 3 | import isDev from 'electron-is-dev'; 4 | 5 | let win: BrowserWindow; 6 | 7 | // the hack of all hacks 8 | // electron doesn't have a built in notification thing, 9 | // so we launch a window on which we can use the 10 | // HTML5 `Notification` API :'( 11 | 12 | let buffer: string[][] = []; 13 | 14 | function notify(title: string, body = '', details: any = {}) { 15 | //eslint-disable-next-line no-console 16 | console.log(`[Notification] ${title}: ${body}`); 17 | if (details.error) { 18 | //eslint-disable-next-line no-console 19 | console.error(details.error); 20 | } 21 | if (win) { 22 | win.webContents.send('notification', {title, body}); 23 | } else { 24 | buffer.push([title, body]); 25 | } 26 | } 27 | 28 | app.on('ready', () => { 29 | const win_ = new BrowserWindow({ 30 | show: false, 31 | webPreferences: { 32 | nodeIntegration: true 33 | } 34 | }); 35 | const url = `file://${resolve(isDev ? __dirname : app.getAppPath(), 'notify.html')}`; 36 | win_.loadURL(url); 37 | win_.webContents.on('dom-ready', () => { 38 | win = win_; 39 | buffer.forEach(([title, body]) => { 40 | notify(title, body); 41 | }); 42 | buffer = []; 43 | }); 44 | }); 45 | 46 | export default notify; 47 | -------------------------------------------------------------------------------- /app/plugins/install.ts: -------------------------------------------------------------------------------- 1 | import cp from 'child_process'; 2 | import queue from 'queue'; 3 | import ms from 'ms'; 4 | import {yarn, plugs} from '../config/paths'; 5 | 6 | export const install = (fn: Function) => { 7 | const spawnQueue = queue({concurrency: 1}); 8 | function yarnFn(args: string[], cb: Function) { 9 | const env = { 10 | NODE_ENV: 'production', 11 | ELECTRON_RUN_AS_NODE: 'true' 12 | }; 13 | spawnQueue.push(end => { 14 | const cmd = [process.execPath, yarn].concat(args).join(' '); 15 | //eslint-disable-next-line no-console 16 | console.log('Launching yarn:', cmd); 17 | 18 | cp.execFile( 19 | process.execPath, 20 | [yarn].concat(args), 21 | { 22 | cwd: plugs.base, 23 | env, 24 | timeout: ms('5m'), 25 | maxBuffer: 1024 * 1024 26 | }, 27 | (err, stdout, stderr) => { 28 | if (err) { 29 | cb(stderr); 30 | } else { 31 | cb(null); 32 | } 33 | end?.(); 34 | spawnQueue.start(); 35 | } 36 | ); 37 | }); 38 | 39 | spawnQueue.start(); 40 | } 41 | 42 | yarnFn(['install', '--no-emoji', '--no-lockfile', '--cache-folder', plugs.cache], (err: any) => { 43 | if (err) { 44 | return fn(err); 45 | } 46 | fn(null); 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /app/menus/menus/darwin.ts: -------------------------------------------------------------------------------- 1 | // This menu label is overrided by OSX to be the appName 2 | // The label is set to appName here so it matches actual behavior 3 | import {app, BrowserWindow, MenuItemConstructorOptions} from 'electron'; 4 | 5 | export default ( 6 | commandKeys: Record, 7 | execCommand: (command: string, focusedWindow?: BrowserWindow) => void, 8 | showAbout: () => void 9 | ): MenuItemConstructorOptions => { 10 | return { 11 | label: `${app.getName()}`, 12 | submenu: [ 13 | { 14 | label: 'About Hyper', 15 | click() { 16 | showAbout(); 17 | } 18 | }, 19 | { 20 | type: 'separator' 21 | }, 22 | { 23 | label: 'Preferences...', 24 | accelerator: commandKeys['window:preferences'], 25 | click() { 26 | execCommand('window:preferences'); 27 | } 28 | }, 29 | { 30 | type: 'separator' 31 | }, 32 | { 33 | role: 'services', 34 | submenu: [] 35 | }, 36 | { 37 | type: 'separator' 38 | }, 39 | { 40 | role: 'hide' 41 | }, 42 | { 43 | role: 'hideOthers' 44 | }, 45 | { 46 | role: 'unhide' 47 | }, 48 | { 49 | type: 'separator' 50 | }, 51 | { 52 | role: 'quit' 53 | } 54 | ] 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | - [ ] I am on the [latest](https://github.com/zeit/hyper/releases/latest) Hyper.app version 11 | - [ ] I have searched the [issues](https://github.com/zeit/hyper/issues) of this repo and believe that this is not a duplicate 12 | 13 | 17 | 18 | - **OS version and name**: 19 | - **Hyper.app version**: 20 | - **Link of a [Gist](https://gist.github.com/) with the contents of your .hyper.js**: 21 | - **Relevant information from devtools** _(CMD+ALT+I on macOS, CTRL+SHIFT+I elsewhere)_: 22 | - **The issue is reproducible in vanilla Hyper.app**: 23 | 24 | ## Issue 25 | 26 | -------------------------------------------------------------------------------- /lib/command-registry.ts: -------------------------------------------------------------------------------- 1 | import {remote} from 'electron'; 2 | // TODO: Should be updates to new async API https://medium.com/@nornagon/electrons-remote-module-considered-harmful-70d69500f31 3 | 4 | const {getDecoratedKeymaps} = remote.require('./plugins'); 5 | 6 | let commands: Record = {}; 7 | 8 | export const getRegisteredKeys = () => { 9 | const keymaps = getDecoratedKeymaps(); 10 | 11 | return Object.keys(keymaps).reduce((result: Record, actionName) => { 12 | const commandKeys = keymaps[actionName]; 13 | commandKeys.forEach((shortcut: string) => { 14 | result[shortcut] = actionName; 15 | }); 16 | return result; 17 | }, {}); 18 | }; 19 | 20 | export const registerCommandHandlers = (cmds: typeof commands) => { 21 | if (!cmds) { 22 | return; 23 | } 24 | 25 | commands = Object.assign(commands, cmds); 26 | }; 27 | 28 | export const getCommandHandler = (command: string) => { 29 | return commands[command]; 30 | }; 31 | 32 | // Some commands are directly excuted by Electron menuItem role. 33 | // They should not be prevented to reach Electron. 34 | const roleCommands = [ 35 | 'window:close', 36 | 'editor:undo', 37 | 'editor:redo', 38 | 'editor:cut', 39 | 'editor:copy', 40 | 'editor:paste', 41 | 'editor:selectAll', 42 | 'window:minimize', 43 | 'window:zoom', 44 | 'window:toggleFullScreen' 45 | ]; 46 | 47 | export const shouldPreventDefault = (command: string) => !roleCommands.includes(command); 48 | -------------------------------------------------------------------------------- /app/ui/contextmenu.ts: -------------------------------------------------------------------------------- 1 | import editMenu from '../menus/menus/edit'; 2 | import shellMenu from '../menus/menus/shell'; 3 | import {execCommand} from '../commands'; 4 | import {getDecoratedKeymaps} from '../plugins'; 5 | import {MenuItemConstructorOptions, BrowserWindow} from 'electron'; 6 | const separator: MenuItemConstructorOptions = {type: 'separator'}; 7 | 8 | const getCommandKeys = (keymaps: Record): Record => 9 | Object.keys(keymaps).reduce((commandKeys: Record, command) => { 10 | return Object.assign(commandKeys, { 11 | [command]: keymaps[command][0] 12 | }); 13 | }, {}); 14 | 15 | // only display cut/copy when there's a cursor selection 16 | const filterCutCopy = (selection: string, menuItem: MenuItemConstructorOptions) => { 17 | if (/^cut$|^copy$/.test(menuItem.role!) && !selection) { 18 | return; 19 | } 20 | return menuItem; 21 | }; 22 | 23 | export default ( 24 | createWindow: (fn?: (win: BrowserWindow) => void, options?: Record) => BrowserWindow, 25 | selection: string 26 | ) => { 27 | const commandKeys = getCommandKeys(getDecoratedKeymaps()); 28 | const _shell = shellMenu(commandKeys, execCommand).submenu as MenuItemConstructorOptions[]; 29 | const _edit = editMenu(commandKeys, execCommand).submenu.filter(filterCutCopy.bind(null, selection)); 30 | return _edit 31 | .concat(separator, _shell) 32 | .filter(menuItem => !Object.prototype.hasOwnProperty.call(menuItem, 'enabled') || menuItem.enabled); 33 | }; 34 | -------------------------------------------------------------------------------- /app/auto-updater-linux.js: -------------------------------------------------------------------------------- 1 | import fetch from 'electron-fetch'; 2 | import {EventEmitter} from 'events'; 3 | 4 | class AutoUpdater extends EventEmitter { 5 | quitAndInstall() { 6 | this.emitError('QuitAndInstall unimplemented'); 7 | } 8 | getFeedURL() { 9 | return this.updateURL; 10 | } 11 | 12 | setFeedURL(updateURL) { 13 | this.updateURL = updateURL; 14 | } 15 | 16 | checkForUpdates() { 17 | if (!this.updateURL) { 18 | return this.emitError('Update URL is not set'); 19 | } 20 | this.emit('checking-for-update'); 21 | 22 | fetch(this.updateURL) 23 | .then(res => { 24 | if (res.status === 204) { 25 | return this.emit('update-not-available'); 26 | } 27 | return res.json().then(({name, notes, pub_date}) => { 28 | // Only name is mandatory, needed to construct release URL. 29 | if (!name) { 30 | throw new Error('Malformed server response: release name is missing.'); 31 | } 32 | // If `null` is passed to Date constructor, current time will be used. This doesn't work with `undefined` 33 | const date = new Date(pub_date || null); 34 | this.emit('update-available', {}, notes, name, date); 35 | }); 36 | }) 37 | .catch(this.emitError.bind(this)); 38 | } 39 | 40 | emitError(error) { 41 | if (typeof error === 'string') { 42 | error = new Error(error); 43 | } 44 | this.emit('error', error, error.message); 45 | } 46 | } 47 | 48 | export default new AutoUpdater(); 49 | -------------------------------------------------------------------------------- /app/keymaps/win32.json: -------------------------------------------------------------------------------- 1 | { 2 | "window:devtools": "ctrl+shift+i", 3 | "window:reload": "ctrl+shift+r", 4 | "window:reloadFull": "ctrl+shift+f5", 5 | "window:preferences": "ctrl+,", 6 | "window:hamburgerMenu": "alt", 7 | "zoom:reset": "ctrl+0", 8 | "zoom:in": "ctrl+=", 9 | "zoom:out": "ctrl+-", 10 | "window:new": "ctrl+shift+n", 11 | "window:minimize": "ctrl+shift+m", 12 | "window:zoom": "ctrl+shift+alt+m", 13 | "window:toggleFullScreen": "f11", 14 | "window:close": [ 15 | "ctrl+shift+q", 16 | "alt+f4" 17 | ], 18 | "tab:new": "ctrl+shift+t", 19 | "tab:jump:prefix": "ctrl", 20 | "pane:next": "ctrl+pageup", 21 | "pane:prev": "ctrl+pagedown", 22 | "pane:splitRight": "ctrl+shift+d", 23 | "pane:splitDown": "ctrl+shift+e", 24 | "pane:close": "ctrl+shift+w", 25 | "editor:undo": "ctrl+shift+z", 26 | "editor:redo": "ctrl+shift+y", 27 | "editor:cut": "ctrl+shift+x", 28 | "editor:copy": "ctrl+shift+c", 29 | "editor:paste": "ctrl+shift+v", 30 | "editor:selectAll": "ctrl+shift+a", 31 | "editor:search": "ctrl+shift+f", 32 | "editor:search-close": "esc", 33 | "editor:movePreviousWord": "", 34 | "editor:moveNextWord": "", 35 | "editor:moveBeginningLine": "Home", 36 | "editor:moveEndLine": "End", 37 | "editor:deletePreviousWord": "ctrl+backspace", 38 | "editor:deleteNextWord": "ctrl+del", 39 | "editor:deleteBeginningLine": "ctrl+home", 40 | "editor:deleteEndLine": "ctrl+end", 41 | "editor:clearBuffer": "ctrl+shift+k", 42 | "editor:break": "ctrl+c", 43 | "plugins:update": "ctrl+shift+u" 44 | } 45 | -------------------------------------------------------------------------------- /electron-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/electron-builder", 3 | "appId": "co.zeit.hyper", 4 | "directories": { 5 | "app": "target" 6 | }, 7 | "extraResources": [ 8 | "./bin/yarn-standalone.js", 9 | "./bin/cli.js", 10 | { 11 | "from": "./build/${os}/", 12 | "to": "./bin/", 13 | "filter": [ 14 | "hyper*" 15 | ] 16 | } 17 | ], 18 | "linux": { 19 | "category": "TerminalEmulator", 20 | "target": [ 21 | { 22 | "target": "deb", 23 | "arch": [ 24 | "x64" 25 | ] 26 | }, 27 | { 28 | "target": "AppImage", 29 | "arch": [ 30 | "x64" 31 | ] 32 | }, 33 | { 34 | "target": "rpm", 35 | "arch": [ 36 | "x64" 37 | ] 38 | }, 39 | { 40 | "target": "snap", 41 | "arch": [ 42 | "x64" 43 | ] 44 | } 45 | ] 46 | }, 47 | "win": { 48 | "target": [ 49 | "squirrel" 50 | ], 51 | "rfc3161TimeStampServer": "http://timestamp.comodoca.com" 52 | }, 53 | "mac": { 54 | "category": "public.app-category.developer-tools", 55 | "extendInfo": "build/Info.plist", 56 | "darkModeSupport": true 57 | }, 58 | "deb": { 59 | "afterInstall": "./build/linux/after-install.tpl" 60 | }, 61 | "rpm": { 62 | "afterInstall": "./build/linux/after-install.tpl" 63 | }, 64 | "snap": { 65 | "confinement": "classic" 66 | }, 67 | "protocols": { 68 | "name": "ssh URL", 69 | "schemes": [ 70 | "ssh" 71 | ] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/rpc.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | import {ipcMain, BrowserWindow} from 'electron'; 3 | import uuid from 'uuid'; 4 | 5 | export class Server extends EventEmitter { 6 | destroyed = false; 7 | win: BrowserWindow; 8 | id!: string; 9 | constructor(win: BrowserWindow) { 10 | super(); 11 | this.win = win; 12 | this.ipcListener = this.ipcListener.bind(this); 13 | 14 | if (this.destroyed) { 15 | return; 16 | } 17 | 18 | const uid = uuid.v4(); 19 | this.id = uid; 20 | 21 | ipcMain.on(uid, this.ipcListener); 22 | 23 | // we intentionally subscribe to `on` instead of `once` 24 | // to support reloading the window and re-initializing 25 | // the channel 26 | this.wc.on('did-finish-load', () => { 27 | this.wc.send('init', uid); 28 | }); 29 | } 30 | 31 | get wc() { 32 | return this.win.webContents; 33 | } 34 | 35 | ipcListener(event: any, {ev, data}: {ev: string; data: any}) { 36 | super.emit(ev, data); 37 | } 38 | 39 | emit(ch: string, data: any = {}): any { 40 | // This check is needed because data-batching can cause extra data to be 41 | // emitted after the window has already closed 42 | if (!this.win.isDestroyed()) { 43 | this.wc.send(this.id, {ch, data}); 44 | } 45 | } 46 | 47 | destroy() { 48 | this.removeAllListeners(); 49 | this.wc.removeAllListeners(); 50 | if (this.id) { 51 | ipcMain.removeListener(this.id, this.ipcListener); 52 | } else { 53 | // mark for `genUid` in constructor 54 | this.destroyed = true; 55 | } 56 | } 57 | } 58 | 59 | export default (win: BrowserWindow) => { 60 | return new Server(win); 61 | }; 62 | -------------------------------------------------------------------------------- /app/utils/map-keys.ts: -------------------------------------------------------------------------------- 1 | const generatePrefixedCommand = (command: string, shortcuts: string[]) => { 2 | const result: Record = {}; 3 | const baseCmd = command.replace(/:prefix$/, ''); 4 | for (let i = 1; i <= 9; i++) { 5 | // 9 is a special number because it means 'last' 6 | const index = i === 9 ? 'last' : i; 7 | const prefixedShortcuts = shortcuts.map(shortcut => `${shortcut}+${i}`); 8 | result[`${baseCmd}:${index}`] = prefixedShortcuts; 9 | } 10 | 11 | return result; 12 | }; 13 | 14 | export default (config: Record) => { 15 | return Object.keys(config).reduce((keymap: Record, command: string) => { 16 | if (!command) { 17 | return keymap; 18 | } 19 | // We can have different keys for a same command. 20 | const _shortcuts = config[command]; 21 | const shortcuts = Array.isArray(_shortcuts) ? _shortcuts : [_shortcuts]; 22 | const fixedShortcuts: string[] = []; 23 | shortcuts.forEach(shortcut => { 24 | let newShortcut = shortcut; 25 | if (newShortcut.indexOf('cmd') !== -1) { 26 | // Mousetrap use `command` and not `cmd` 27 | //eslint-disable-next-line no-console 28 | console.warn('Your config use deprecated `cmd` in key combination. Please use `command` instead.'); 29 | newShortcut = newShortcut.replace('cmd', 'command'); 30 | } 31 | fixedShortcuts.push(newShortcut); 32 | }); 33 | 34 | if (command.endsWith(':prefix')) { 35 | return Object.assign(keymap, generatePrefixedCommand(command, fixedShortcuts)); 36 | } 37 | 38 | keymap[command] = fixedShortcuts; 39 | 40 | return keymap; 41 | }, {}); 42 | }; 43 | -------------------------------------------------------------------------------- /app/config/init.ts: -------------------------------------------------------------------------------- 1 | import vm from 'vm'; 2 | import notify from '../notify'; 3 | import mapKeys from '../utils/map-keys'; 4 | 5 | const _extract = (script?: vm.Script): Record => { 6 | const module: Record = {}; 7 | script?.runInNewContext({module}); 8 | if (!module.exports) { 9 | throw new Error('Error reading configuration: `module.exports` not set'); 10 | } 11 | return module.exports; 12 | }; 13 | 14 | const _syntaxValidation = (cfg: string) => { 15 | try { 16 | return new vm.Script(cfg, {filename: '.hyper.js', displayErrors: true}); 17 | } catch (err) { 18 | notify('Error loading config:', `${err.name}, see DevTools for more info`, {error: err}); 19 | } 20 | }; 21 | 22 | const _extractDefault = (cfg: string) => { 23 | return _extract(_syntaxValidation(cfg)); 24 | }; 25 | 26 | // init config 27 | const _init = (cfg: {userCfg: string; defaultCfg: Record}) => { 28 | const script = _syntaxValidation(cfg.userCfg); 29 | if (script) { 30 | const _cfg = _extract(script); 31 | if (!_cfg.config) { 32 | notify('Error reading configuration: `config` key is missing'); 33 | return cfg.defaultCfg; 34 | } 35 | // Merging platform specific keymaps with user defined keymaps 36 | _cfg.keymaps = mapKeys(Object.assign({}, cfg.defaultCfg.keymaps, _cfg.keymaps)); 37 | // Ignore undefined values in plugin and localPlugins array Issue #1862 38 | _cfg.plugins = (_cfg.plugins && _cfg.plugins.filter(Boolean)) || []; 39 | _cfg.localPlugins = (_cfg.localPlugins && _cfg.localPlugins.filter(Boolean)) || []; 40 | return _cfg; 41 | } 42 | return cfg.defaultCfg; 43 | }; 44 | 45 | export {_init, _extractDefault}; 46 | -------------------------------------------------------------------------------- /app/keymaps/linux.json: -------------------------------------------------------------------------------- 1 | { 2 | "window:devtools": "ctrl+shift+i", 3 | "window:reload": "ctrl+shift+r", 4 | "window:reloadFull": "ctrl+shift+f5", 5 | "window:preferences": "ctrl+,", 6 | "zoom:reset": "ctrl+0", 7 | "zoom:in": "ctrl+=", 8 | "zoom:out": "ctrl+-", 9 | "window:new": "ctrl+shift+n", 10 | "window:minimize": "ctrl+shift+m", 11 | "window:zoom": "ctrl+shift+alt+m", 12 | "window:toggleFullScreen": "f11", 13 | "window:close": "ctrl+shift+q", 14 | "tab:new": "ctrl+shift+t", 15 | "tab:next": [ 16 | "ctrl+shift+]", 17 | "ctrl+shift+right", 18 | "ctrl+alt+right", 19 | "ctrl+tab" 20 | ], 21 | "tab:prev": [ 22 | "ctrl+shift+[", 23 | "ctrl+shift+left", 24 | "ctrl+alt+left", 25 | "ctrl+shift+tab" 26 | ], 27 | "tab:jump:prefix": "ctrl", 28 | "pane:next": "ctrl+pageup", 29 | "pane:prev": "ctrl+pagedown", 30 | "pane:splitRight": "ctrl+shift+d", 31 | "pane:splitDown": "ctrl+shift+e", 32 | "pane:close": "ctrl+shift+w", 33 | "editor:undo": "ctrl+shift+z", 34 | "editor:redo": "ctrl+shift+y", 35 | "editor:cut": "ctrl+shift+x", 36 | "editor:copy": "ctrl+shift+c", 37 | "editor:paste": "ctrl+shift+v", 38 | "editor:selectAll": "ctrl+shift+a", 39 | "editor:search": "ctrl+shift+f", 40 | "editor:search-close": "esc", 41 | "editor:movePreviousWord": "ctrl+left", 42 | "editor:moveNextWord": "ctrl+right", 43 | "editor:moveBeginningLine": "home", 44 | "editor:moveEndLine": "end", 45 | "editor:deletePreviousWord": "ctrl+backspace", 46 | "editor:deleteNextWord": "ctrl+del", 47 | "editor:deleteBeginningLine": "ctrl+home", 48 | "editor:deleteEndLine": "ctrl+end", 49 | "editor:clearBuffer": "ctrl+shift+k", 50 | "editor:break": "ctrl+c", 51 | "plugins:update": "ctrl+shift+u" 52 | } 53 | -------------------------------------------------------------------------------- /app/menus/menus/view.ts: -------------------------------------------------------------------------------- 1 | import {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 2 | 3 | export default ( 4 | commandKeys: Record, 5 | execCommand: (command: string, focusedWindow?: BrowserWindow) => void 6 | ): MenuItemConstructorOptions => { 7 | return { 8 | label: 'View', 9 | submenu: [ 10 | { 11 | label: 'Reload', 12 | accelerator: commandKeys['window:reload'], 13 | click(item, focusedWindow) { 14 | execCommand('window:reload', focusedWindow); 15 | } 16 | }, 17 | { 18 | label: 'Full Reload', 19 | accelerator: commandKeys['window:reloadFull'], 20 | click(item, focusedWindow) { 21 | execCommand('window:reloadFull', focusedWindow); 22 | } 23 | }, 24 | { 25 | label: 'Developer Tools', 26 | accelerator: commandKeys['window:devtools'], 27 | click: (item, focusedWindow) => { 28 | execCommand('window:devtools', focusedWindow); 29 | } 30 | }, 31 | { 32 | type: 'separator' 33 | }, 34 | { 35 | label: 'Reset Zoom Level', 36 | accelerator: commandKeys['zoom:reset'], 37 | click(item, focusedWindow) { 38 | execCommand('zoom:reset', focusedWindow); 39 | } 40 | }, 41 | { 42 | label: 'Zoom In', 43 | accelerator: commandKeys['zoom:in'], 44 | click(item, focusedWindow) { 45 | execCommand('zoom:in', focusedWindow); 46 | } 47 | }, 48 | { 49 | label: 'Zoom Out', 50 | accelerator: commandKeys['zoom:out'], 51 | click(item, focusedWindow) { 52 | execCommand('zoom:out', focusedWindow); 53 | } 54 | } 55 | ] 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /lib/utils/rpc.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | import {IpcRenderer, IpcRendererEvent} from 'electron'; 3 | import electron from 'electron'; 4 | export default class Client { 5 | emitter: EventEmitter; 6 | ipc: IpcRenderer; 7 | id!: string; 8 | constructor() { 9 | this.emitter = new EventEmitter(); 10 | this.ipc = electron.ipcRenderer; 11 | if (window.__rpcId) { 12 | setTimeout(() => { 13 | this.id = window.__rpcId; 14 | this.ipc.on(this.id, this.ipcListener); 15 | this.emitter.emit('ready'); 16 | }, 0); 17 | } else { 18 | this.ipc.on('init', (ev: IpcRendererEvent, uid: string) => { 19 | // we cache so that if the object 20 | // gets re-instantiated we don't 21 | // wait for a `init` event 22 | window.__rpcId = uid; 23 | this.id = uid; 24 | this.ipc.on(uid, this.ipcListener); 25 | this.emitter.emit('ready'); 26 | }); 27 | } 28 | } 29 | 30 | ipcListener = (event: any, {ch, data}: {ch: string; data: any}) => { 31 | this.emitter.emit(ch, data); 32 | }; 33 | 34 | on(ev: string, fn: (...args: any[]) => void) { 35 | this.emitter.on(ev, fn); 36 | } 37 | 38 | once(ev: string, fn: (...args: any[]) => void) { 39 | this.emitter.once(ev, fn); 40 | } 41 | 42 | emit(ev: string, data: any) { 43 | if (!this.id) { 44 | throw new Error('Not ready'); 45 | } 46 | this.ipc.send(this.id, {ev, data}); 47 | } 48 | 49 | removeListener(ev: string, fn: (...args: any[]) => void) { 50 | this.emitter.removeListener(ev, fn); 51 | } 52 | 53 | removeAllListeners() { 54 | this.emitter.removeAllListeners(); 55 | } 56 | 57 | destroy() { 58 | this.removeAllListeners(); 59 | this.ipc.removeAllListeners(this.id); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/menus/menus/shell.ts: -------------------------------------------------------------------------------- 1 | import {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 2 | 3 | export default ( 4 | commandKeys: Record, 5 | execCommand: (command: string, focusedWindow?: BrowserWindow) => void 6 | ): MenuItemConstructorOptions => { 7 | const isMac = process.platform === 'darwin'; 8 | 9 | return { 10 | label: isMac ? 'Shell' : 'File', 11 | submenu: [ 12 | { 13 | label: 'New Tab', 14 | accelerator: commandKeys['tab:new'], 15 | click(item, focusedWindow) { 16 | execCommand('tab:new', focusedWindow); 17 | } 18 | }, 19 | { 20 | label: 'New Window', 21 | accelerator: commandKeys['window:new'], 22 | click(item, focusedWindow) { 23 | execCommand('window:new', focusedWindow); 24 | } 25 | }, 26 | { 27 | type: 'separator' 28 | }, 29 | { 30 | label: 'Split Down', 31 | accelerator: commandKeys['pane:splitDown'], 32 | click(item, focusedWindow) { 33 | execCommand('pane:splitDown', focusedWindow); 34 | } 35 | }, 36 | { 37 | label: 'Split Right', 38 | accelerator: commandKeys['pane:splitRight'], 39 | click(item, focusedWindow) { 40 | execCommand('pane:splitRight', focusedWindow); 41 | } 42 | }, 43 | { 44 | type: 'separator' 45 | }, 46 | { 47 | label: 'Close', 48 | accelerator: commandKeys['pane:close'], 49 | click(item, focusedWindow) { 50 | execCommand('pane:close', focusedWindow); 51 | } 52 | }, 53 | { 54 | label: isMac ? 'Close Window' : 'Quit', 55 | role: 'close', 56 | accelerator: commandKeys['window:close'] 57 | } 58 | ] 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /app/keymaps/darwin.json: -------------------------------------------------------------------------------- 1 | { 2 | "window:devtools": "command+alt+i", 3 | "window:reload": "command+shift+r", 4 | "window:reloadFull": "command+shift+f5", 5 | "window:preferences": "command+,", 6 | "zoom:reset": "command+0", 7 | "zoom:in": [ 8 | "command+plus", 9 | "command+=" 10 | ], 11 | "zoom:out": "command+-", 12 | "window:new": "command+n", 13 | "window:minimize": "command+m", 14 | "window:zoom": "ctrl+alt+command+m", 15 | "window:toggleFullScreen": "command+ctrl+f", 16 | "window:close": "command+shift+w", 17 | "tab:new": "command+t", 18 | "tab:next": [ 19 | "command+shift+]", 20 | "command+shift+right", 21 | "command+alt+right", 22 | "ctrl+tab" 23 | ], 24 | "tab:prev": [ 25 | "command+shift+[", 26 | "command+shift+left", 27 | "command+alt+left", 28 | "ctrl+shift+tab" 29 | ], 30 | "tab:jump:prefix": "command", 31 | "pane:next": "command+]", 32 | "pane:prev": "command+[", 33 | "pane:splitRight": "command+d", 34 | "pane:splitDown": "command+shift+d", 35 | "pane:close": "command+w", 36 | "editor:undo": "command+z", 37 | "editor:redo": "command+y", 38 | "editor:cut": "command+x", 39 | "editor:copy": "command+c", 40 | "editor:paste": "command+v", 41 | "editor:selectAll": "command+a", 42 | "editor:search": "command+f", 43 | "editor:search-close": "esc", 44 | "editor:movePreviousWord": "alt+left", 45 | "editor:moveNextWord": "alt+right", 46 | "editor:moveBeginningLine": "command+left", 47 | "editor:moveEndLine": "command+right", 48 | "editor:deletePreviousWord": "alt+backspace", 49 | "editor:deleteNextWord": "alt+delete", 50 | "editor:deleteBeginningLine": "command+backspace", 51 | "editor:deleteEndLine": "command+delete", 52 | "editor:clearBuffer": "command+k", 53 | "editor:break": "ctrl+c", 54 | "plugins:update": "command+shift+u" 55 | } 56 | -------------------------------------------------------------------------------- /lib/actions/header.ts: -------------------------------------------------------------------------------- 1 | import {CLOSE_TAB, CHANGE_TAB} from '../constants/tabs'; 2 | import { 3 | UI_WINDOW_MAXIMIZE, 4 | UI_WINDOW_UNMAXIMIZE, 5 | UI_OPEN_HAMBURGER_MENU, 6 | UI_WINDOW_MINIMIZE, 7 | UI_WINDOW_CLOSE 8 | } from '../constants/ui'; 9 | import rpc from '../rpc'; 10 | import {userExitTermGroup, setActiveGroup} from './term-groups'; 11 | import {HyperDispatch} from '../hyper'; 12 | 13 | export function closeTab(uid: string) { 14 | return (dispatch: HyperDispatch) => { 15 | dispatch({ 16 | type: CLOSE_TAB, 17 | uid, 18 | effect() { 19 | dispatch(userExitTermGroup(uid)); 20 | } 21 | }); 22 | }; 23 | } 24 | 25 | export function changeTab(uid: string) { 26 | return (dispatch: HyperDispatch) => { 27 | dispatch({ 28 | type: CHANGE_TAB, 29 | uid, 30 | effect() { 31 | dispatch(setActiveGroup(uid)); 32 | } 33 | }); 34 | }; 35 | } 36 | 37 | export function maximize() { 38 | return (dispatch: HyperDispatch) => { 39 | dispatch({ 40 | type: UI_WINDOW_MAXIMIZE, 41 | effect() { 42 | rpc.emit('maximize', null); 43 | } 44 | }); 45 | }; 46 | } 47 | 48 | export function unmaximize() { 49 | return (dispatch: HyperDispatch) => { 50 | dispatch({ 51 | type: UI_WINDOW_UNMAXIMIZE, 52 | effect() { 53 | rpc.emit('unmaximize', null); 54 | } 55 | }); 56 | }; 57 | } 58 | 59 | export function openHamburgerMenu(coordinates: {x: number; y: number}) { 60 | return (dispatch: HyperDispatch) => { 61 | dispatch({ 62 | type: UI_OPEN_HAMBURGER_MENU, 63 | effect() { 64 | rpc.emit('open hamburger menu', coordinates); 65 | } 66 | }); 67 | }; 68 | } 69 | 70 | export function minimize() { 71 | return (dispatch: HyperDispatch) => { 72 | dispatch({ 73 | type: UI_WINDOW_MINIMIZE, 74 | effect() { 75 | rpc.emit('minimize', null); 76 | } 77 | }); 78 | }; 79 | } 80 | 81 | export function close() { 82 | return (dispatch: HyperDispatch) => { 83 | dispatch({ 84 | type: UI_WINDOW_CLOSE, 85 | effect() { 86 | rpc.emit('close', null); 87 | } 88 | }); 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /lib/components/searchBox.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const searchBoxStyling = { 4 | float: 'right', 5 | height: '28px', 6 | backgroundColor: 'white', 7 | position: 'absolute', 8 | right: '10px', 9 | top: '25px', 10 | width: '224px', 11 | zIndex: '9999' 12 | }; 13 | 14 | const enterKey = 13; 15 | 16 | export default class SearchBox extends React.PureComponent { 17 | constructor(props) { 18 | super(props); 19 | this.searchTerm = ''; 20 | } 21 | 22 | handleChange = event => { 23 | this.searchTerm = event.target.value; 24 | if (event.keyCode === enterKey) { 25 | this.props.search(event.target.value); 26 | } 27 | }; 28 | 29 | render() { 30 | return ( 31 |
32 | input && input.focus()} /> 33 | this.props.prev(this.searchTerm)}> 34 | {' '} 35 | ←{' '} 36 | 37 | this.props.next(this.searchTerm)}> 38 | {' '} 39 | →{' '} 40 | 41 | this.props.close()}> 42 | {' '} 43 | x{' '} 44 | 45 | 74 |
75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/containers/notifications.ts: -------------------------------------------------------------------------------- 1 | import Notifications from '../components/notifications'; 2 | import {installUpdate} from '../actions/updater'; 3 | import {connect} from '../utils/plugins'; 4 | import {dismissNotification} from '../actions/notifications'; 5 | import {HyperState} from '../hyper'; 6 | import {Dispatch} from 'redux'; 7 | 8 | const NotificationsContainer = connect( 9 | (state: HyperState) => { 10 | const {ui} = state; 11 | const {notifications} = ui; 12 | const state_ = {}; 13 | 14 | if (notifications.font) { 15 | const fontSize = ui.fontSizeOverride || ui.fontSize; 16 | 17 | Object.assign(state_, { 18 | fontShowing: true, 19 | fontSize, 20 | fontText: `${fontSize}px` 21 | }); 22 | } 23 | 24 | if (notifications.resize) { 25 | const cols = ui.cols; 26 | const rows = ui.rows; 27 | 28 | Object.assign(state_, { 29 | resizeShowing: true, 30 | cols, 31 | rows 32 | }); 33 | } 34 | 35 | if (notifications.updates) { 36 | Object.assign(state_, { 37 | updateShowing: true, 38 | updateVersion: ui.updateVersion, 39 | updateNote: ui.updateNotes!.split('\n')[0], 40 | updateReleaseUrl: ui.updateReleaseUrl, 41 | updateCanInstall: ui.updateCanInstall 42 | }); 43 | } else if (notifications.message) { 44 | Object.assign(state_, { 45 | messageShowing: true, 46 | messageText: ui.messageText, 47 | messageURL: ui.messageURL, 48 | messageDismissable: ui.messageDismissable 49 | }); 50 | } 51 | 52 | return state_; 53 | }, 54 | (dispatch: Dispatch) => { 55 | return { 56 | onDismissFont: () => { 57 | dispatch(dismissNotification('font')); 58 | }, 59 | onDismissResize: () => { 60 | dispatch(dismissNotification('resize')); 61 | }, 62 | onDismissUpdate: () => { 63 | dispatch(dismissNotification('updates')); 64 | }, 65 | onDismissMessage: () => { 66 | dispatch(dismissNotification('message')); 67 | }, 68 | onUpdateInstall: () => { 69 | dispatch(installUpdate()); 70 | } 71 | }; 72 | }, 73 | null 74 | )(Notifications, 'Notifications'); 75 | 76 | export default NotificationsContainer; 77 | -------------------------------------------------------------------------------- /test/unit/window-utils.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | const proxyquire = require('proxyquire').noCallThru(); 3 | 4 | test('positionIsValid() returns true when window is on only screen', t => { 5 | const position = [50, 50]; 6 | const windowUtils = proxyquire('../../app/utils/window-utils', { 7 | electron: { 8 | screen: { 9 | getAllDisplays: () => { 10 | return [ 11 | { 12 | workArea: { 13 | x: 0, 14 | y: 0, 15 | width: 500, 16 | height: 500 17 | } 18 | } 19 | ]; 20 | } 21 | } 22 | } 23 | }); 24 | 25 | const result = windowUtils.positionIsValid(position); 26 | 27 | t.true(result); 28 | }); 29 | 30 | test('positionIsValid() returns true when window is on second screen', t => { 31 | const position = [750, 50]; 32 | const windowUtils = proxyquire('../../app/utils/window-utils', { 33 | electron: { 34 | screen: { 35 | getAllDisplays: () => { 36 | return [ 37 | { 38 | workArea: { 39 | x: 0, 40 | y: 0, 41 | width: 500, 42 | height: 500 43 | } 44 | }, 45 | { 46 | workArea: { 47 | x: 500, 48 | y: 0, 49 | width: 500, 50 | height: 500 51 | } 52 | } 53 | ]; 54 | } 55 | } 56 | } 57 | }); 58 | 59 | const result = windowUtils.positionIsValid(position); 60 | 61 | t.true(result); 62 | }); 63 | 64 | test('positionIsValid() returns false when position isnt valid', t => { 65 | const primaryDisplay = { 66 | workArea: { 67 | x: 0, 68 | y: 0, 69 | width: 500, 70 | height: 500 71 | } 72 | }; 73 | const position = [600, 50]; 74 | const windowUtils = proxyquire('../../app/utils/window-utils', { 75 | electron: { 76 | screen: { 77 | getAllDisplays: () => { 78 | return [primaryDisplay]; 79 | }, 80 | getPrimaryDisplay: () => primaryDisplay 81 | } 82 | } 83 | }); 84 | 85 | const result = windowUtils.positionIsValid(position); 86 | 87 | t.false(result); 88 | }); 89 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | install: 4 | macos: 5 | xcode: "11.2.1" 6 | working_directory: ~/repo 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | key: cache-{{ checksum "yarn.lock" }} 11 | - run: 12 | name: Installing Dependencies 13 | command: yarn --ignore-engines 14 | - save_cache: 15 | key: cache-{{ checksum "yarn.lock" }} 16 | paths: 17 | - node_modules 18 | - run: 19 | name: Getting build icon 20 | command: if [[ $CIRCLE_BRANCH == canary ]]; then cp build/canary.icns build/icon.icns; fi 21 | - persist_to_workspace: 22 | root: . 23 | paths: 24 | - node_modules 25 | - app/node_modules 26 | 27 | test: 28 | macos: 29 | xcode: "11.2.1" 30 | steps: 31 | - checkout 32 | - attach_workspace: 33 | at: . 34 | - run: 35 | name: Testing 36 | command: yarn test 37 | 38 | build: 39 | macos: 40 | xcode: "11.2.1" 41 | steps: 42 | - checkout 43 | - attach_workspace: 44 | at: . 45 | - run: 46 | name: Building 47 | command: yarn dist --publish 'never' 48 | - store_artifacts: 49 | path: dist 50 | - persist_to_workspace: 51 | root: . 52 | paths: 53 | - dist 54 | 55 | release: 56 | macos: 57 | xcode: "11.2.1" 58 | steps: 59 | - checkout 60 | - attach_workspace: 61 | at: . 62 | - run: 63 | name: Deploying to GitHub 64 | command: yarn dist 65 | 66 | 67 | workflows: 68 | version: 2 69 | build: 70 | jobs: 71 | - install: 72 | filters: 73 | tags: 74 | only: /.*/ 75 | - test: 76 | requires: 77 | - install 78 | filters: 79 | tags: 80 | only: /.*/ 81 | - build: 82 | requires: 83 | - test 84 | filters: 85 | branches: 86 | only: 87 | - master 88 | - canary 89 | tags: 90 | ignore: /.*/ 91 | - release: 92 | requires: 93 | - test 94 | filters: 95 | tags: 96 | only: /.*/ 97 | branches: 98 | ignore: /.*/ 99 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "react", 4 | "prettier", 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:prettier/recommended" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaVersion": 8, 15 | "sourceType": "module", 16 | "ecmaFeatures": { 17 | "jsx": true, 18 | "impliedStrict": true, 19 | "experimentalObjectRestSpread": true 20 | }, 21 | "allowImportExportEverywhere": true 22 | }, 23 | "env": { 24 | "es6": true, 25 | "browser": true, 26 | "node": true 27 | }, 28 | "settings": { 29 | "react": { 30 | "version": "detect" 31 | } 32 | }, 33 | "rules": { 34 | "func-names": [ 35 | "error", 36 | "as-needed" 37 | ], 38 | "no-shadow": "error", 39 | "no-extra-semi": 0, 40 | "react/prop-types": 0, 41 | "react/react-in-jsx-scope": 0, 42 | "react/no-unescaped-entities": 0, 43 | "react/jsx-no-target-blank": 0, 44 | "react/no-string-refs": 0, 45 | "prettier/prettier": [ 46 | "error", 47 | { 48 | "printWidth": 120, 49 | "tabWidth": 2, 50 | "singleQuote": true, 51 | "trailingComma": "none", 52 | "bracketSpacing": false, 53 | "semi": true, 54 | "useTabs": false, 55 | "jsxBracketSameLine": false 56 | } 57 | ] 58 | }, 59 | "overrides": [ 60 | { 61 | "files": [ 62 | "app/config/config-default.js", 63 | ".hyper.js" 64 | ], 65 | "rules": { 66 | "prettier/prettier": [ 67 | "error", 68 | { 69 | "printWidth": 120, 70 | "tabWidth": 2, 71 | "singleQuote": true, 72 | "trailingComma": "es5", 73 | "bracketSpacing": false, 74 | "semi": true, 75 | "useTabs": false, 76 | "parser": "babel", 77 | "jsxBracketSameLine": false 78 | } 79 | ] 80 | } 81 | }, 82 | { 83 | "files": [ 84 | "**.ts", 85 | "**.tsx" 86 | ], 87 | "extends": [ 88 | "plugin:@typescript-eslint/recommended", 89 | "prettier/@typescript-eslint" 90 | ], 91 | "rules": { 92 | "@typescript-eslint/explicit-function-return-type": "off", 93 | "@typescript-eslint/no-explicit-any": "off", 94 | "@typescript-eslint/no-non-null-assertion": "off" 95 | } 96 | } 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /app/menus/menus/window.ts: -------------------------------------------------------------------------------- 1 | import {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 2 | 3 | export default ( 4 | commandKeys: Record, 5 | execCommand: (command: string, focusedWindow?: BrowserWindow) => void 6 | ): MenuItemConstructorOptions => { 7 | // Generating tab:jump array 8 | const tabJump = []; 9 | for (let i = 1; i <= 9; i++) { 10 | // 9 is a special number because it means 'last' 11 | const label = i === 9 ? 'Last' : `${i}`; 12 | tabJump.push({ 13 | label, 14 | accelerator: commandKeys[`tab:jump:${label.toLowerCase()}`] 15 | }); 16 | } 17 | 18 | return { 19 | role: 'window', 20 | submenu: [ 21 | { 22 | role: 'minimize', 23 | accelerator: commandKeys['window:minimize'] 24 | }, 25 | { 26 | type: 'separator' 27 | }, 28 | { 29 | // It's the same thing as clicking the green traffc-light on macOS 30 | role: 'zoom', 31 | accelerator: commandKeys['window:zoom'] 32 | }, 33 | { 34 | label: 'Select Tab', 35 | submenu: [ 36 | { 37 | label: 'Previous', 38 | accelerator: commandKeys['tab:prev'], 39 | click: (item, focusedWindow) => { 40 | execCommand('tab:prev', focusedWindow); 41 | } 42 | }, 43 | { 44 | label: 'Next', 45 | accelerator: commandKeys['tab:next'], 46 | click: (item, focusedWindow) => { 47 | execCommand('tab:next', focusedWindow); 48 | } 49 | }, 50 | { 51 | type: 'separator' 52 | }, 53 | ...tabJump 54 | ] 55 | }, 56 | { 57 | type: 'separator' 58 | }, 59 | { 60 | label: 'Select Pane', 61 | submenu: [ 62 | { 63 | label: 'Previous', 64 | accelerator: commandKeys['pane:prev'], 65 | click: (item, focusedWindow) => { 66 | execCommand('pane:prev', focusedWindow); 67 | } 68 | }, 69 | { 70 | label: 'Next', 71 | accelerator: commandKeys['pane:next'], 72 | click: (item, focusedWindow) => { 73 | execCommand('pane:next', focusedWindow); 74 | } 75 | } 76 | ] 77 | }, 78 | { 79 | type: 'separator' 80 | }, 81 | { 82 | role: 'front' 83 | }, 84 | { 85 | role: 'togglefullscreen', 86 | accelerator: commandKeys['window:toggleFullScreen'] 87 | } 88 | ] 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /lib/containers/header.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-params */ 2 | import {createSelector} from 'reselect'; 3 | 4 | import Header from '../components/header'; 5 | import {closeTab, changeTab, maximize, openHamburgerMenu, unmaximize, minimize, close} from '../actions/header'; 6 | import {connect} from '../utils/plugins'; 7 | import {getRootGroups} from '../selectors'; 8 | import {HyperState} from '../hyper'; 9 | import {Dispatch} from 'redux'; 10 | 11 | const isMac = /Mac/.test(navigator.userAgent); 12 | 13 | const getSessions = ({sessions}: HyperState) => sessions.sessions; 14 | const getActiveRootGroup = ({termGroups}: HyperState) => termGroups.activeRootGroup; 15 | const getActiveSessions = ({termGroups}: HyperState) => termGroups.activeSessions; 16 | const getActivityMarkers = ({ui}: HyperState) => ui.activityMarkers; 17 | const getTabs = createSelector( 18 | [getSessions, getRootGroups, getActiveSessions, getActiveRootGroup, getActivityMarkers], 19 | (sessions, rootGroups, activeSessions, activeRootGroup, activityMarkers) => 20 | rootGroups.map(t => { 21 | const activeSessionUid = activeSessions[t.uid]; 22 | const session = sessions[activeSessionUid]; 23 | return { 24 | uid: t.uid, 25 | title: session.title, 26 | isActive: t.uid === activeRootGroup, 27 | hasActivity: activityMarkers[session.uid] 28 | }; 29 | }) 30 | ); 31 | 32 | export const HeaderContainer = connect( 33 | (state: HyperState) => { 34 | return { 35 | // active is an index 36 | isMac, 37 | tabs: getTabs(state), 38 | activeMarkers: state.ui.activityMarkers, 39 | borderColor: state.ui.borderColor, 40 | backgroundColor: state.ui.backgroundColor, 41 | maximized: state.ui.maximized, 42 | fullScreen: state.ui.fullScreen, 43 | showHamburgerMenu: state.ui.showHamburgerMenu, 44 | showWindowControls: state.ui.showWindowControls 45 | }; 46 | }, 47 | (dispatch: Dispatch) => { 48 | return { 49 | onCloseTab: (i: string) => { 50 | dispatch(closeTab(i)); 51 | }, 52 | 53 | onChangeTab: (i: string) => { 54 | dispatch(changeTab(i)); 55 | }, 56 | 57 | maximize: () => { 58 | dispatch(maximize()); 59 | }, 60 | 61 | unmaximize: () => { 62 | dispatch(unmaximize()); 63 | }, 64 | 65 | openHamburgerMenu: (coordinates: {x: number; y: number}) => { 66 | dispatch(openHamburgerMenu(coordinates)); 67 | }, 68 | 69 | minimize: () => { 70 | dispatch(minimize()); 71 | }, 72 | 73 | close: () => { 74 | dispatch(close()); 75 | } 76 | }; 77 | }, 78 | null 79 | )(Header, 'Header'); 80 | -------------------------------------------------------------------------------- /app/config/open.ts: -------------------------------------------------------------------------------- 1 | import {shell} from 'electron'; 2 | import {cfgPath} from './paths'; 3 | export default () => Promise.resolve(shell.openItem(cfgPath)); 4 | 5 | // Windows opens .js files with WScript.exe by default 6 | // If the user hasn't set up an editor for .js files, we fallback to notepad. 7 | if (process.platform === 'win32') { 8 | const Registry = require('winreg') as typeof import('winreg'); 9 | const {exec} = require('child_process') as typeof import('child_process'); 10 | 11 | const getUserChoiceKey = async () => { 12 | // Load FileExts keys for .js files 13 | const keys: Winreg.Registry[] = await new Promise((resolve, reject) => { 14 | new Registry({ 15 | hive: Registry.HKCU, 16 | key: '\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\.js' 17 | }).keys((error, items) => { 18 | if (error) { 19 | reject(error); 20 | } else { 21 | resolve(items || []); 22 | } 23 | }); 24 | }); 25 | 26 | // Find UserChoice key 27 | const userChoice = keys.find(k => k.key.endsWith('UserChoice')); 28 | return userChoice; 29 | }; 30 | 31 | const hasDefaultSet = async () => { 32 | const userChoice = await getUserChoiceKey(); 33 | if (!userChoice) return false; 34 | 35 | // Load key values 36 | const values: string[] = await new Promise((resolve, reject) => { 37 | userChoice.values((error, items) => { 38 | if (error) { 39 | reject(error); 40 | } 41 | resolve(items.map(item => item.value || '') || []); 42 | }); 43 | }); 44 | 45 | // Look for default program 46 | const hasDefaultProgramConfigured = values.every( 47 | value => value && typeof value === 'string' && !value.includes('WScript.exe') && !value.includes('JSFile') 48 | ); 49 | 50 | return hasDefaultProgramConfigured; 51 | }; 52 | 53 | // This mimics shell.openItem, true if it worked, false if not. 54 | const openNotepad = (file: string) => 55 | new Promise(resolve => { 56 | exec(`start notepad.exe ${file}`, error => { 57 | resolve(!error); 58 | }); 59 | }); 60 | 61 | module.exports = () => 62 | hasDefaultSet() 63 | .then(yes => { 64 | if (yes) { 65 | return shell.openItem(cfgPath); 66 | } 67 | //eslint-disable-next-line no-console 68 | console.warn('No default app set for .js files, using notepad.exe fallback'); 69 | return openNotepad(cfgPath); 70 | }) 71 | .catch(err => { 72 | //eslint-disable-next-line no-console 73 | console.error('Open config with default app error:', err); 74 | return openNotepad(cfgPath); 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /app/config/paths.ts: -------------------------------------------------------------------------------- 1 | // This module exports paths, names, and other metadata that is referenced 2 | import {homedir} from 'os'; 3 | import {app} from 'electron'; 4 | import {statSync} from 'fs'; 5 | import {resolve, join} from 'path'; 6 | import isDev from 'electron-is-dev'; 7 | 8 | const cfgFile = '.hyper.js'; 9 | const defaultCfgFile = 'config-default.js'; 10 | const homeDirectory = homedir(); 11 | 12 | // If the user defines XDG_CONFIG_HOME they definitely want their config there, 13 | // otherwise use the home directory in linux/mac and userdata in windows 14 | const applicationDirectory = 15 | process.env.XDG_CONFIG_HOME !== undefined 16 | ? join(process.env.XDG_CONFIG_HOME, 'hyper') 17 | : process.platform == 'win32' 18 | ? app.getPath('userData') 19 | : homedir(); 20 | 21 | let cfgDir = applicationDirectory; 22 | let cfgPath = join(applicationDirectory, cfgFile); 23 | const legacyCfgPath = join(homeDirectory, cfgFile); // Hyper 2 config location 24 | 25 | const devDir = resolve(__dirname, '../..'); 26 | const devCfg = join(devDir, cfgFile); 27 | const defaultCfg = resolve(__dirname, defaultCfgFile); 28 | 29 | if (isDev) { 30 | // if a local config file exists, use it 31 | try { 32 | statSync(devCfg); 33 | cfgPath = devCfg; 34 | cfgDir = devDir; 35 | //eslint-disable-next-line no-console 36 | console.log('using config file:', cfgPath); 37 | } catch (err) { 38 | // ignore 39 | } 40 | } 41 | 42 | const plugins = resolve(cfgDir, '.hyper_plugins'); 43 | const plugs = { 44 | legacyBase: resolve(homeDirectory, '.hyper_plugins'), 45 | legacyLocal: resolve(homeDirectory, '.hyper_plugins', 'local'), 46 | base: plugins, 47 | local: resolve(plugins, 'local'), 48 | cache: resolve(plugins, 'cache') 49 | }; 50 | const yarn = resolve(__dirname, '../../bin/yarn-standalone.js'); 51 | const cliScriptPath = resolve(__dirname, '../../bin/hyper'); 52 | const cliLinkPath = '/usr/local/bin/hyper'; 53 | 54 | const icon = resolve(__dirname, '../static/icon96x96.png'); 55 | 56 | const keymapPath = resolve(__dirname, '../keymaps'); 57 | const darwinKeys = join(keymapPath, 'darwin.json'); 58 | const win32Keys = join(keymapPath, 'win32.json'); 59 | const linuxKeys = join(keymapPath, 'linux.json'); 60 | 61 | const defaultPlatformKeyPath = () => { 62 | switch (process.platform) { 63 | case 'darwin': 64 | return darwinKeys; 65 | case 'win32': 66 | return win32Keys; 67 | case 'linux': 68 | return linuxKeys; 69 | default: 70 | return darwinKeys; 71 | } 72 | }; 73 | 74 | export { 75 | cfgDir, 76 | cfgPath, 77 | legacyCfgPath, 78 | cfgFile, 79 | defaultCfg, 80 | icon, 81 | defaultPlatformKeyPath, 82 | plugs, 83 | yarn, 84 | cliScriptPath, 85 | cliLinkPath, 86 | homeDirectory 87 | }; 88 | -------------------------------------------------------------------------------- /app/menus/menus/help.ts: -------------------------------------------------------------------------------- 1 | import {release} from 'os'; 2 | import {app, shell, MenuItemConstructorOptions} from 'electron'; 3 | import {getConfig, getPlugins} from '../../config'; 4 | const {arch, env, platform, versions} = process; 5 | import {version} from '../../package.json'; 6 | 7 | export default (commands: Record, showAbout: () => void): MenuItemConstructorOptions => { 8 | const submenu: MenuItemConstructorOptions[] = [ 9 | { 10 | label: `${app.getName()} Website`, 11 | click() { 12 | shell.openExternal('https://hyper.is'); 13 | } 14 | }, 15 | { 16 | label: 'Report Issue', 17 | click() { 18 | const body = ` 19 | 25 | 26 | - [ ] Your Hyper.app version is **${version}**. Please verify your using the [latest](https://github.com/zeit/hyper/releases/latest) Hyper.app version 27 | - [ ] I have searched the [issues](https://github.com/zeit/hyper/issues) of this repo and believe that this is not a duplicate 28 | 29 | --- 30 | - **Any relevant information from devtools?** _(CMD+ALT+I on macOS, CTRL+SHIFT+I elsewhere)_: 31 | 32 | 33 | - **Is the issue reproducible in vanilla Hyper.app?** 34 | 35 | 36 | ## Issue 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | - **${app.getName()} version**: ${env.TERM_PROGRAM_VERSION} "${app.getVersion()}" 46 | 47 | - **OS ARCH VERSION:** ${platform} ${arch} ${release()} 48 | - **Electron:** ${versions.electron} **LANG:** ${env.LANG} 49 | - **SHELL:** ${env.SHELL} **TERM:** ${env.TERM} 50 | 51 |
52 | ~/.hyper.js contents 53 |
54 |         
55 |           ${JSON.stringify(getConfig(), null, 2)}
56 | 
57 |           ${JSON.stringify(getPlugins(), null, 2)}
58 |         
59 |       
60 |
`; 61 | 62 | shell.openExternal(`https://github.com/zeit/hyper/issues/new?body=${encodeURIComponent(body)}`); 63 | } 64 | } 65 | ]; 66 | 67 | if (process.platform !== 'darwin') { 68 | submenu.push( 69 | {type: 'separator'}, 70 | { 71 | role: 'about', 72 | click() { 73 | showAbout(); 74 | } 75 | } 76 | ); 77 | } 78 | return { 79 | role: 'help', 80 | submenu 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /app/system-context-menu.ts: -------------------------------------------------------------------------------- 1 | import Registry from 'winreg'; 2 | 3 | const appPath = `"${process.execPath}"`; 4 | const regKey = `\\Software\\Classes\\Directory\\background\\shell\\Hyper`; 5 | const regParts = [ 6 | {key: 'command', name: '', value: `${appPath} "%V"`}, 7 | {name: '', value: 'Open Hyper here'}, 8 | {name: 'Icon', value: `${appPath}`} 9 | ]; 10 | 11 | function addValues(hyperKey: Registry.Registry, commandKey: Registry.Registry, callback: Function) { 12 | hyperKey.set(regParts[1].name, Registry.REG_SZ, regParts[1].value, error => { 13 | if (error) { 14 | //eslint-disable-next-line no-console 15 | console.error(error.message); 16 | } 17 | hyperKey.set(regParts[2].name, Registry.REG_SZ, regParts[2].value, err => { 18 | if (err) { 19 | //eslint-disable-next-line no-console 20 | console.error(err.message); 21 | } 22 | commandKey.set(regParts[0].name, Registry.REG_SZ, regParts[0].value, err_ => { 23 | if (err_) { 24 | //eslint-disable-next-line no-console 25 | console.error(err_.message); 26 | } 27 | callback(); 28 | }); 29 | }); 30 | }); 31 | } 32 | 33 | export const add = (callback: Function) => { 34 | const hyperKey = new Registry({hive: 'HKCU', key: regKey}); 35 | const commandKey = new Registry({ 36 | hive: 'HKCU', 37 | key: `${regKey}\\${regParts[0].key}` 38 | }); 39 | 40 | hyperKey.keyExists((error, exists) => { 41 | if (error) { 42 | //eslint-disable-next-line no-console 43 | console.error(error.message); 44 | } 45 | if (exists) { 46 | commandKey.keyExists((err_, exists_) => { 47 | if (err_) { 48 | //eslint-disable-next-line no-console 49 | console.error(err_.message); 50 | } 51 | if (exists_) { 52 | addValues(hyperKey, commandKey, callback); 53 | } else { 54 | commandKey.create(err => { 55 | if (err) { 56 | //eslint-disable-next-line no-console 57 | console.error(err.message); 58 | } 59 | addValues(hyperKey, commandKey, callback); 60 | }); 61 | } 62 | }); 63 | } else { 64 | hyperKey.create(err => { 65 | if (err) { 66 | //eslint-disable-next-line no-console 67 | console.error(err.message); 68 | } 69 | commandKey.create(err_ => { 70 | if (err_) { 71 | //eslint-disable-next-line no-console 72 | console.error(err_.message); 73 | } 74 | addValues(hyperKey, commandKey, callback); 75 | }); 76 | }); 77 | } 78 | }); 79 | }; 80 | 81 | export const remove = (callback: Function) => { 82 | new Registry({hive: 'HKCU', key: regKey}).destroy(err => { 83 | if (err) { 84 | //eslint-disable-next-line no-console 85 | console.error(err.message); 86 | } 87 | callback(); 88 | }); 89 | }; 90 | -------------------------------------------------------------------------------- /app/menus/menu.ts: -------------------------------------------------------------------------------- 1 | // Packages 2 | import {app, dialog, Menu, BrowserWindow} from 'electron'; 3 | 4 | // Utilities 5 | import {getConfig} from '../config'; 6 | import {icon} from '../config/paths'; 7 | import viewMenu from './menus/view'; 8 | import shellMenu from './menus/shell'; 9 | import editMenu from './menus/edit'; 10 | import pluginsMenu from './menus/plugins'; 11 | import windowMenu from './menus/window'; 12 | import helpMenu from './menus/help'; 13 | import darwinMenu from './menus/darwin'; 14 | import {getDecoratedKeymaps} from '../plugins'; 15 | import {execCommand} from '../commands'; 16 | import {getRendererTypes} from '../utils/renderer-utils'; 17 | 18 | const appName = app.getName(); 19 | const appVersion = app.getVersion(); 20 | 21 | let menu_: Menu; 22 | 23 | export const createMenu = ( 24 | createWindow: (fn?: (win: BrowserWindow) => void, options?: Record) => BrowserWindow, 25 | getLoadedPluginVersions: () => {name: string; version: string}[] 26 | ) => { 27 | const config = getConfig(); 28 | // We take only first shortcut in array for each command 29 | const allCommandKeys = getDecoratedKeymaps(); 30 | const commandKeys = Object.keys(allCommandKeys).reduce((result: Record, command) => { 31 | result[command] = allCommandKeys[command][0]; 32 | return result; 33 | }, {}); 34 | 35 | let updateChannel = 'stable'; 36 | 37 | if (config && config.updateChannel && config.updateChannel === 'canary') { 38 | updateChannel = 'canary'; 39 | } 40 | 41 | const showAbout = () => { 42 | const loadedPlugins = getLoadedPluginVersions(); 43 | const pluginList = 44 | loadedPlugins.length === 0 ? 'none' : loadedPlugins.map(plugin => `\n ${plugin.name} (${plugin.version})`); 45 | 46 | const rendererCounts = Object.values(getRendererTypes()).reduce((acc: Record, type) => { 47 | acc[type] = acc[type] ? acc[type] + 1 : 1; 48 | return acc; 49 | }, {}); 50 | const renderers = Object.entries(rendererCounts) 51 | .map(([type, count]) => type + (count > 1 ? ` (${count})` : '')) 52 | .join(', '); 53 | 54 | dialog.showMessageBox({ 55 | title: `About ${appName}`, 56 | message: `${appName} ${appVersion} (${updateChannel})`, 57 | detail: `Renderers: ${renderers}\nPlugins: ${pluginList}\n\nCreated by Guillermo Rauch\nCopyright © 2020 ZEIT, Inc.`, 58 | buttons: [], 59 | icon: icon as any 60 | }); 61 | }; 62 | const menu = [ 63 | ...(process.platform === 'darwin' ? [darwinMenu(commandKeys, execCommand, showAbout)] : []), 64 | shellMenu(commandKeys, execCommand), 65 | editMenu(commandKeys, execCommand), 66 | viewMenu(commandKeys, execCommand), 67 | pluginsMenu(commandKeys, execCommand), 68 | windowMenu(commandKeys, execCommand), 69 | helpMenu(commandKeys, showAbout) 70 | ]; 71 | 72 | return menu; 73 | }; 74 | 75 | export const buildMenu = (template: Electron.MenuItemConstructorOptions[]): Electron.Menu => { 76 | menu_ = Menu.buildFromTemplate(template); 77 | return menu_; 78 | }; 79 | -------------------------------------------------------------------------------- /lib/containers/terms.ts: -------------------------------------------------------------------------------- 1 | import Terms from '../components/terms'; 2 | import {connect} from '../utils/plugins'; 3 | import {resizeSession, sendSessionData, setSessionXtermTitle, setActiveSession, onSearch} from '../actions/sessions'; 4 | 5 | import {openContextMenu} from '../actions/ui'; 6 | import {getRootGroups} from '../selectors'; 7 | import {HyperState, TermsProps} from '../hyper'; 8 | import {Dispatch} from 'redux'; 9 | 10 | const TermsContainer = connect( 11 | (state: HyperState): TermsProps => { 12 | const {sessions} = state.sessions; 13 | return { 14 | sessions, 15 | cols: state.ui.cols, 16 | rows: state.ui.rows, 17 | scrollback: state.ui.scrollback, 18 | termGroups: getRootGroups(state), 19 | activeRootGroup: state.termGroups.activeRootGroup, 20 | activeSession: state.sessions.activeUid, 21 | customCSS: state.ui.termCSS, 22 | write: state.sessions.write, 23 | fontSize: state.ui.fontSizeOverride ? state.ui.fontSizeOverride : state.ui.fontSize, 24 | fontFamily: state.ui.fontFamily, 25 | fontWeight: state.ui.fontWeight, 26 | fontWeightBold: state.ui.fontWeightBold, 27 | lineHeight: state.ui.lineHeight, 28 | letterSpacing: state.ui.letterSpacing, 29 | uiFontFamily: state.ui.uiFontFamily, 30 | fontSmoothing: state.ui.fontSmoothingOverride, 31 | padding: state.ui.padding, 32 | cursorColor: state.ui.cursorColor, 33 | cursorAccentColor: state.ui.cursorAccentColor, 34 | cursorShape: state.ui.cursorShape, 35 | cursorBlink: state.ui.cursorBlink, 36 | borderColor: state.ui.borderColor, 37 | selectionColor: state.ui.selectionColor, 38 | colors: state.ui.colors, 39 | foregroundColor: state.ui.foregroundColor, 40 | backgroundColor: state.ui.backgroundColor, 41 | bell: state.ui.bell, 42 | bellSoundURL: state.ui.bellSoundURL, 43 | bellSound: state.ui.bellSound, 44 | copyOnSelect: state.ui.copyOnSelect, 45 | modifierKeys: state.ui.modifierKeys, 46 | quickEdit: state.ui.quickEdit, 47 | webGLRenderer: state.ui.webGLRenderer, 48 | macOptionSelectionMode: state.ui.macOptionSelectionMode, 49 | disableLigatures: state.ui.disableLigatures 50 | }; 51 | }, 52 | (dispatch: Dispatch) => { 53 | return { 54 | onData(uid: string, data: any) { 55 | dispatch(sendSessionData(uid, data)); 56 | }, 57 | 58 | onTitle(uid: string, title: string) { 59 | dispatch(setSessionXtermTitle(uid, title)); 60 | }, 61 | 62 | onResize(uid: string, cols: number, rows: number) { 63 | dispatch(resizeSession(uid, cols, rows)); 64 | }, 65 | 66 | onActive(uid: string) { 67 | dispatch(setActiveSession(uid)); 68 | }, 69 | toggleSearch(uid: string) { 70 | dispatch(onSearch(uid)); 71 | }, 72 | 73 | onContextMenu(uid: string, selection: any) { 74 | dispatch(setActiveSession(uid)); 75 | dispatch(openContextMenu(uid, selection)); 76 | } 77 | }; 78 | }, 79 | null, 80 | {forwardRef: true} 81 | )(Terms, 'Terms'); 82 | 83 | export default TermsContainer; 84 | -------------------------------------------------------------------------------- /app/updater.js: -------------------------------------------------------------------------------- 1 | // Packages 2 | import electron from 'electron'; 3 | const {app} = electron; 4 | import ms from 'ms'; 5 | import retry from 'async-retry'; 6 | 7 | // Utilities 8 | // eslint-disable-next-line no-unused-vars 9 | import {version} from './package'; 10 | import {getDecoratedConfig} from './plugins'; 11 | 12 | const {platform} = process; 13 | const isLinux = platform === 'linux'; 14 | 15 | const autoUpdater = isLinux ? require('./auto-updater-linux').default : electron.autoUpdater; 16 | 17 | let isInit = false; 18 | // Default to the "stable" update channel 19 | let canaryUpdates = false; 20 | 21 | const buildFeedUrl = (canary, currentVersion) => { 22 | const updatePrefix = canary ? 'releases-canary' : 'releases'; 23 | return `https://${updatePrefix}.hyper.is/update/${isLinux ? 'deb' : platform}/${currentVersion}`; 24 | }; 25 | 26 | const isCanary = updateChannel => updateChannel === 'canary'; 27 | 28 | async function init() { 29 | autoUpdater.on('error', (err, msg) => { 30 | //eslint-disable-next-line no-console 31 | console.error('Error fetching updates', `${msg} (${err.stack})`); 32 | }); 33 | 34 | const config = await retry(async () => { 35 | const content = await getDecoratedConfig(); 36 | 37 | if (!content) { 38 | throw new Error('No config content loaded'); 39 | } 40 | 41 | return content; 42 | }); 43 | 44 | // If defined in the config, switch to the "canary" channel 45 | if (config.updateChannel && isCanary(config.updateChannel)) { 46 | canaryUpdates = true; 47 | } 48 | 49 | const feedURL = buildFeedUrl(canaryUpdates, version); 50 | 51 | autoUpdater.setFeedURL(feedURL); 52 | 53 | setTimeout(() => { 54 | autoUpdater.checkForUpdates(); 55 | }, ms('10s')); 56 | 57 | setInterval(() => { 58 | autoUpdater.checkForUpdates(); 59 | }, ms('30m')); 60 | 61 | isInit = true; 62 | } 63 | 64 | export default win => { 65 | if (!isInit) { 66 | init(); 67 | } 68 | 69 | const {rpc} = win; 70 | 71 | const onupdate = (ev, releaseNotes, releaseName, date, updateUrl, onQuitAndInstall) => { 72 | const releaseUrl = updateUrl || `https://github.com/zeit/hyper/releases/tag/${releaseName}`; 73 | rpc.emit('update available', {releaseNotes, releaseName, releaseUrl, canInstall: !!onQuitAndInstall}); 74 | }; 75 | 76 | const eventName = isLinux ? 'update-available' : 'update-downloaded'; 77 | 78 | autoUpdater.on(eventName, onupdate); 79 | 80 | rpc.once('quit and install', () => { 81 | autoUpdater.quitAndInstall(); 82 | }); 83 | 84 | app.config.subscribe(() => { 85 | const {updateChannel} = app.plugins.getDecoratedConfig(); 86 | const newUpdateIsCanary = isCanary(updateChannel); 87 | 88 | if (newUpdateIsCanary !== canaryUpdates) { 89 | const feedURL = buildFeedUrl(newUpdateIsCanary, version); 90 | 91 | autoUpdater.setFeedURL(feedURL); 92 | autoUpdater.checkForUpdates(); 93 | 94 | canaryUpdates = newUpdateIsCanary; 95 | } 96 | }); 97 | 98 | win.on('close', () => { 99 | autoUpdater.removeListener(eventName, onupdate); 100 | }); 101 | }; 102 | -------------------------------------------------------------------------------- /lib/constants/sessions.ts: -------------------------------------------------------------------------------- 1 | export const SESSION_ADD = 'SESSION_ADD'; 2 | export const SESSION_RESIZE = 'SESSION_RESIZE'; 3 | export const SESSION_REQUEST = 'SESSION_REQUEST'; 4 | export const SESSION_ADD_DATA = 'SESSION_ADD_DATA'; 5 | export const SESSION_PTY_DATA = 'SESSION_PTY_DATA'; 6 | export const SESSION_PTY_EXIT = 'SESSION_PTY_EXIT'; 7 | export const SESSION_USER_EXIT = 'SESSION_USER_EXIT'; 8 | export const SESSION_SET_ACTIVE = 'SESSION_SET_ACTIVE'; 9 | export const SESSION_CLEAR_ACTIVE = 'SESSION_CLEAR_ACTIVE'; 10 | export const SESSION_USER_DATA = 'SESSION_USER_DATA'; 11 | export const SESSION_URL_SET = 'SESSION_URL_SET'; 12 | export const SESSION_URL_UNSET = 'SESSION_URL_UNSET'; 13 | export const SESSION_SET_XTERM_TITLE = 'SESSION_SET_XTERM_TITLE'; 14 | export const SESSION_SET_CWD = 'SESSION_SET_CWD'; 15 | export const SESSION_SEARCH = 'SESSION_SEARCH'; 16 | export const SESSION_SEARCH_CLOSE = 'SESSION_SEARCH_CLOSE'; 17 | 18 | export interface SessionAddAction { 19 | type: typeof SESSION_ADD; 20 | uid: string; 21 | shell: string | null; 22 | pid: number | null; 23 | cols: number | null; 24 | rows: number | null; 25 | splitDirection?: string; 26 | activeUid: string | null; 27 | now: number; 28 | } 29 | export interface SessionResizeAction { 30 | type: typeof SESSION_RESIZE; 31 | uid: string; 32 | cols: number; 33 | rows: number; 34 | isStandaloneTerm: boolean; 35 | now: number; 36 | } 37 | export interface SessionRequestAction { 38 | type: typeof SESSION_REQUEST; 39 | } 40 | export interface SessionAddDataAction { 41 | type: typeof SESSION_ADD_DATA; 42 | } 43 | export interface SessionPtyDataAction { 44 | type: typeof SESSION_PTY_DATA; 45 | uid: string; 46 | now: number; 47 | } 48 | export interface SessionPtyExitAction { 49 | type: typeof SESSION_PTY_EXIT; 50 | uid: string; 51 | } 52 | export interface SessionUserExitAction { 53 | type: typeof SESSION_USER_EXIT; 54 | uid: string; 55 | } 56 | export interface SessionSetActiveAction { 57 | type: typeof SESSION_SET_ACTIVE; 58 | uid: string; 59 | } 60 | export interface SessionClearActiveAction { 61 | type: typeof SESSION_CLEAR_ACTIVE; 62 | } 63 | export interface SessionUserDataAction { 64 | type: typeof SESSION_USER_DATA; 65 | } 66 | export interface SessionUrlSetAction { 67 | type: typeof SESSION_URL_SET; 68 | uid: string; 69 | } 70 | export interface SessionUrlUnsetAction { 71 | type: typeof SESSION_URL_UNSET; 72 | uid: string; 73 | } 74 | export interface SessionSetXtermTitleAction { 75 | type: typeof SESSION_SET_XTERM_TITLE; 76 | uid: string; 77 | title: string; 78 | } 79 | export interface SessionSetCwdAction { 80 | type: typeof SESSION_SET_CWD; 81 | cwd: string; 82 | } 83 | export interface SessionSearchAction { 84 | type: typeof SESSION_SEARCH; 85 | uid: string; 86 | } 87 | export interface SessionSearchCloseAction { 88 | type: typeof SESSION_SEARCH_CLOSE; 89 | uid: string; 90 | } 91 | 92 | export type SessionActions = 93 | | SessionAddAction 94 | | SessionResizeAction 95 | | SessionRequestAction 96 | | SessionAddDataAction 97 | | SessionPtyDataAction 98 | | SessionPtyExitAction 99 | | SessionUserExitAction 100 | | SessionSetActiveAction 101 | | SessionClearActiveAction 102 | | SessionUserDataAction 103 | | SessionUrlSetAction 104 | | SessionUrlUnsetAction 105 | | SessionSetXtermTitleAction 106 | | SessionSetCwdAction 107 | | SessionSearchAction 108 | | SessionSearchCloseAction; 109 | -------------------------------------------------------------------------------- /assets/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | close tab 5 | 6 | 7 | 8 | hamburger menu 9 | 10 | 11 | 12 | 13 | 14 | minimize window 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | maximize window 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | restore window 39 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 48 | 49 | 50 | 51 | close window 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /lib/components/tabs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {decorate, getTabProps} from '../utils/plugins'; 4 | 5 | import Tab_ from './tab'; 6 | 7 | const Tab = decorate(Tab_, 'Tab'); 8 | const isMac = /Mac/.test(navigator.userAgent); 9 | 10 | export default class Tabs extends React.PureComponent { 11 | render() { 12 | const {tabs = [], borderColor, onChange, onClose, fullScreen} = this.props; 13 | 14 | const hide = !isMac && tabs.length === 1; 15 | 16 | return ( 17 | 102 | ); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/components/notification.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Notification extends React.PureComponent { 4 | constructor() { 5 | super(); 6 | this.state = { 7 | dismissing: false 8 | }; 9 | } 10 | 11 | componentDidMount() { 12 | if (this.props.dismissAfter) { 13 | this.setDismissTimer(); 14 | } 15 | } 16 | //TODO: Remove usage of legacy and soon deprecated lifecycle methods 17 | UNSAFE_componentWillReceiveProps(next) { 18 | // if we have a timer going and the notification text 19 | // changed we reset the timer 20 | if (next.text !== this.props.text) { 21 | if (this.props.dismissAfter) { 22 | this.resetDismissTimer(); 23 | } 24 | if (this.state.dismissing) { 25 | this.setState({dismissing: false}); 26 | } 27 | } 28 | } 29 | 30 | handleDismiss = () => { 31 | this.setState({dismissing: true}); 32 | }; 33 | 34 | onElement = el => { 35 | if (el) { 36 | el.addEventListener('webkitTransitionEnd', () => { 37 | if (this.state.dismissing) { 38 | this.props.onDismiss(); 39 | } 40 | }); 41 | const {backgroundColor} = this.props; 42 | if (backgroundColor) { 43 | el.style.setProperty('background-color', backgroundColor, 'important'); 44 | } 45 | } 46 | }; 47 | 48 | setDismissTimer() { 49 | this.dismissTimer = setTimeout(() => { 50 | this.handleDismiss(); 51 | }, this.props.dismissAfter); 52 | } 53 | 54 | resetDismissTimer() { 55 | clearTimeout(this.dismissTimer); 56 | this.setDismissTimer(); 57 | } 58 | 59 | componentWillUnmount() { 60 | clearTimeout(this.dismissTimer); 61 | } 62 | 63 | render() { 64 | const {backgroundColor, color} = this.props; 65 | const opacity = this.state.dismissing ? 0 : 1; 66 | return ( 67 |
68 | {this.props.customChildrenBefore} 69 | {this.props.children || this.props.text} 70 | {this.props.userDismissable ? ( 71 | 76 | [x] 77 | 78 | ) : null} 79 | {this.props.customChildren} 80 | 81 | 110 |
111 | ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import Copy from 'copy-webpack-plugin'; 2 | import path from 'path'; 3 | import TerserPlugin from 'terser-webpack-plugin'; 4 | import webpack from 'webpack'; 5 | 6 | const nodeEnv = process.env.NODE_ENV || 'development'; 7 | const isProd = nodeEnv === 'production'; 8 | 9 | const config: webpack.Configuration[] = [ 10 | { 11 | mode: 'none', 12 | name: 'hyper-app', 13 | resolve: { 14 | extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'] 15 | }, 16 | entry: './app/index.ts', 17 | output: { 18 | path: path.join(__dirname, 'target'), 19 | filename: 'ignore_this.js' 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.(js|jsx|ts|tsx)$/, 25 | exclude: /node_modules/, 26 | loader: 'null-loader' 27 | } 28 | ] 29 | }, 30 | plugins: [ 31 | new Copy([ 32 | { 33 | from: './app/*.html', 34 | ignore: ['/node_modules/'], 35 | to: '.', 36 | flatten: true 37 | }, 38 | { 39 | from: './app/*.json', 40 | ignore: ['/node_modules/'], 41 | to: '.', 42 | flatten: true 43 | }, 44 | { 45 | from: './app/keymaps/*.json', 46 | ignore: ['/node_modules/'], 47 | to: './keymaps', 48 | flatten: true 49 | }, 50 | { 51 | from: './app/static', 52 | to: './static' 53 | } 54 | ]) 55 | ], 56 | target: 'electron-main' 57 | }, 58 | 59 | { 60 | mode: 'none', 61 | name: 'hyper', 62 | resolve: { 63 | extensions: ['.js', '.jsx', '.ts', '.tsx'] 64 | }, 65 | devtool: isProd ? 'hidden-source-map' : 'cheap-module-source-map', 66 | entry: './lib/index.tsx', 67 | output: { 68 | path: path.join(__dirname, 'target', 'renderer'), 69 | filename: 'bundle.js' 70 | }, 71 | module: { 72 | rules: [ 73 | { 74 | test: /\.(js|jsx|ts|tsx)$/, 75 | exclude: /node_modules/, 76 | loader: 'babel-loader' 77 | }, 78 | { 79 | test: /\.json/, 80 | loader: 'json-loader' 81 | }, 82 | // for xterm.js 83 | { 84 | test: /\.css$/, 85 | loader: 'style-loader!css-loader' 86 | } 87 | ] 88 | }, 89 | plugins: [ 90 | new webpack.IgnorePlugin(/.*\.js.map$/i), 91 | 92 | new webpack.DefinePlugin({ 93 | 'process.env': { 94 | NODE_ENV: JSON.stringify(nodeEnv) 95 | } 96 | }), 97 | new Copy([ 98 | { 99 | from: './assets', 100 | to: './assets' 101 | } 102 | ]) 103 | ], 104 | optimization: { 105 | minimize: isProd ? true : false, 106 | minimizer: [new TerserPlugin()] 107 | }, 108 | target: 'electron-renderer' 109 | }, 110 | { 111 | mode: 'none', 112 | name: 'hyper-cli', 113 | resolve: { 114 | extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'] 115 | }, 116 | devtool: isProd ? false : 'cheap-module-source-map', 117 | entry: './cli/index.ts', 118 | output: { 119 | path: path.join(__dirname, 'bin'), 120 | filename: 'cli.js' 121 | }, 122 | module: { 123 | rules: [ 124 | { 125 | test: /\.(js|jsx|ts|tsx)$/, 126 | exclude: /node_modules/, 127 | loader: 'babel-loader' 128 | }, 129 | { 130 | test: /index.js/, 131 | loader: 'shebang-loader', 132 | include: [/node_modules\/rc/] 133 | } 134 | ] 135 | }, 136 | plugins: [ 137 | // spawn-sync is required by execa if node <= 0.10 138 | new webpack.IgnorePlugin(/(.*\.js.map|spawn-sync)$/i), 139 | new webpack.DefinePlugin({ 140 | 'process.env.NODE_ENV': JSON.stringify(nodeEnv) 141 | }) 142 | ], 143 | optimization: { 144 | minimize: isProd ? true : false, 145 | minimizer: [new TerserPlugin()] 146 | }, 147 | target: 'node' 148 | } 149 | ]; 150 | 151 | export default config; 152 | -------------------------------------------------------------------------------- /lib/reducers/sessions.ts: -------------------------------------------------------------------------------- 1 | import Immutable, {Immutable as ImmutableType} from 'seamless-immutable'; 2 | import {decorateSessionsReducer} from '../utils/plugins'; 3 | import { 4 | SESSION_ADD, 5 | SESSION_PTY_EXIT, 6 | SESSION_USER_EXIT, 7 | SESSION_PTY_DATA, 8 | SESSION_SET_ACTIVE, 9 | SESSION_CLEAR_ACTIVE, 10 | SESSION_RESIZE, 11 | SESSION_SET_XTERM_TITLE, 12 | SESSION_SET_CWD, 13 | SESSION_SEARCH, 14 | SESSION_SEARCH_CLOSE 15 | } from '../constants/sessions'; 16 | import {sessionState, session, HyperActions} from '../hyper'; 17 | 18 | const initialState: ImmutableType = Immutable({ 19 | sessions: {}, 20 | activeUid: null 21 | }); 22 | 23 | function Session(obj: Immutable.DeepPartial) { 24 | const x: session = { 25 | uid: '', 26 | title: '', 27 | cols: null, 28 | rows: null, 29 | url: null, 30 | cleared: false, 31 | search: false, 32 | shell: '', 33 | pid: null 34 | }; 35 | return Immutable(x).merge(obj); 36 | } 37 | 38 | function deleteSession(state: ImmutableType, uid: string) { 39 | return state.updateIn(['sessions'], (sessions: typeof state['sessions']) => { 40 | const sessions_ = sessions.asMutable(); 41 | delete sessions_[uid]; 42 | return sessions_; 43 | }); 44 | } 45 | 46 | const reducer = (state: ImmutableType = initialState, action: HyperActions) => { 47 | switch (action.type) { 48 | case SESSION_ADD: 49 | return state.set('activeUid', action.uid).setIn( 50 | ['sessions', action.uid], 51 | Session({ 52 | cols: action.cols, 53 | rows: action.rows, 54 | uid: action.uid, 55 | shell: action.shell ? action.shell.split('/').pop() : null, 56 | pid: action.pid 57 | }) 58 | ); 59 | 60 | case SESSION_SET_ACTIVE: 61 | return state.set('activeUid', action.uid); 62 | 63 | case SESSION_SEARCH: 64 | return state.setIn(['sessions', action.uid, 'search'], !state.sessions[action.uid].search); 65 | 66 | case SESSION_SEARCH_CLOSE: 67 | return state.setIn(['sessions', action.uid, 'search'], false); 68 | 69 | case SESSION_CLEAR_ACTIVE: 70 | return state.merge( 71 | { 72 | sessions: { 73 | [state.activeUid!]: { 74 | cleared: true 75 | } 76 | } 77 | }, 78 | {deep: true} 79 | ); 80 | 81 | case SESSION_PTY_DATA: 82 | // we avoid a direct merge for perf reasons 83 | // as this is the most common action 84 | if (state.sessions[action.uid] && state.sessions[action.uid].cleared) { 85 | return state.merge( 86 | { 87 | sessions: { 88 | [action.uid]: { 89 | cleared: false 90 | } 91 | } 92 | }, 93 | {deep: true} 94 | ); 95 | } 96 | return state; 97 | 98 | case SESSION_PTY_EXIT: 99 | if (state.sessions[action.uid]) { 100 | return deleteSession(state, action.uid); 101 | } 102 | // eslint-disable-next-line no-console 103 | console.log('ignore pty exit: session removed by user'); 104 | return state; 105 | 106 | case SESSION_USER_EXIT: 107 | return deleteSession(state, action.uid); 108 | 109 | case SESSION_SET_XTERM_TITLE: 110 | return state.setIn( 111 | ['sessions', action.uid, 'title'], 112 | // we need to trim the title because `cmd.exe` 113 | // likes to report ' ' as the title 114 | action.title.trim() 115 | ); 116 | 117 | case SESSION_RESIZE: 118 | return state.setIn( 119 | ['sessions', action.uid], 120 | state.sessions[action.uid].merge({ 121 | rows: action.rows, 122 | cols: action.cols, 123 | resizeAt: action.now 124 | }) 125 | ); 126 | 127 | case SESSION_SET_CWD: 128 | if (state.activeUid) { 129 | return state.setIn(['sessions', state.activeUid, 'cwd'], action.cwd); 130 | } 131 | return state; 132 | 133 | default: 134 | return state; 135 | } 136 | }; 137 | 138 | export type ISessionReducer = typeof reducer; 139 | 140 | export default decorateSessionsReducer(reducer); 141 | -------------------------------------------------------------------------------- /lib/constants/ui.ts: -------------------------------------------------------------------------------- 1 | export const UI_FONT_SIZE_SET = 'UI_FONT_SIZE_SET'; 2 | export const UI_FONT_SIZE_INCR = 'UI_FONT_SIZE_INCR'; 3 | export const UI_FONT_SIZE_DECR = 'UI_FONT_SIZE_DECR'; 4 | export const UI_FONT_SIZE_RESET = 'UI_FONT_SIZE_RESET'; 5 | export const UI_FONT_SMOOTHING_SET = 'UI_FONT_SMOOTHING_SET'; 6 | export const UI_MOVE_LEFT = 'UI_MOVE_LEFT'; 7 | export const UI_MOVE_RIGHT = 'UI_MOVE_RIGHT'; 8 | export const UI_MOVE_TO = 'UI_MOVE_TO'; 9 | export const UI_MOVE_NEXT_PANE = 'UI_MOVE_NEXT_PANE'; 10 | export const UI_MOVE_PREV_PANE = 'UI_MOVE_PREV_PANE'; 11 | export const UI_SHOW_PREFERENCES = 'UI_SHOW_PREFERENCES'; 12 | export const UI_WINDOW_MOVE = 'UI_WINDOW_MOVE'; 13 | export const UI_WINDOW_MAXIMIZE = 'UI_WINDOW_MAXIMIZE'; 14 | export const UI_WINDOW_UNMAXIMIZE = 'UI_WINDOW_UNMAXIMIZE'; 15 | export const UI_WINDOW_GEOMETRY_CHANGED = 'UI_WINDOW_GEOMETRY_CHANGED'; 16 | export const UI_OPEN_FILE = 'UI_OPEN_FILE'; 17 | export const UI_OPEN_SSH_URL = 'UI_OPEN_SSH_URL'; 18 | export const UI_OPEN_HAMBURGER_MENU = 'UI_OPEN_HAMBURGER_MENU'; 19 | export const UI_WINDOW_MINIMIZE = 'UI_WINDOW_MINIMIZE'; 20 | export const UI_WINDOW_CLOSE = 'UI_WINDOW_CLOSE'; 21 | export const UI_ENTER_FULLSCREEN = 'UI_ENTER_FULLSCREEN'; 22 | export const UI_LEAVE_FULLSCREEN = 'UI_LEAVE_FULLSCREEN'; 23 | export const UI_CONTEXTMENU_OPEN = 'UI_CONTEXTMENU_OPEN'; 24 | export const UI_COMMAND_EXEC = 'UI_COMMAND_EXEC'; 25 | 26 | export interface UIFontSizeSetAction { 27 | type: typeof UI_FONT_SIZE_SET; 28 | value: number; 29 | } 30 | export interface UIFontSizeIncrAction { 31 | type: typeof UI_FONT_SIZE_INCR; 32 | } 33 | export interface UIFontSizeDecrAction { 34 | type: typeof UI_FONT_SIZE_DECR; 35 | } 36 | export interface UIFontSizeResetAction { 37 | type: typeof UI_FONT_SIZE_RESET; 38 | } 39 | export interface UIFontSmoothingSetAction { 40 | type: typeof UI_FONT_SMOOTHING_SET; 41 | fontSmoothing: string; 42 | } 43 | export interface UIMoveLeftAction { 44 | type: typeof UI_MOVE_LEFT; 45 | } 46 | export interface UIMoveRightAction { 47 | type: typeof UI_MOVE_RIGHT; 48 | } 49 | export interface UIMoveToAction { 50 | type: typeof UI_MOVE_TO; 51 | } 52 | export interface UIMoveNextPaneAction { 53 | type: typeof UI_MOVE_NEXT_PANE; 54 | } 55 | export interface UIMovePrevPaneAction { 56 | type: typeof UI_MOVE_PREV_PANE; 57 | } 58 | export interface UIShowPreferencesAction { 59 | type: typeof UI_SHOW_PREFERENCES; 60 | } 61 | export interface UIWindowMoveAction { 62 | type: typeof UI_WINDOW_MOVE; 63 | } 64 | export interface UIWindowMaximizeAction { 65 | type: typeof UI_WINDOW_MAXIMIZE; 66 | } 67 | export interface UIWindowUnmaximizeAction { 68 | type: typeof UI_WINDOW_UNMAXIMIZE; 69 | } 70 | export interface UIWindowGeometryChangedAction { 71 | type: typeof UI_WINDOW_GEOMETRY_CHANGED; 72 | } 73 | export interface UIOpenFileAction { 74 | type: typeof UI_OPEN_FILE; 75 | } 76 | export interface UIOpenSshUrlAction { 77 | type: typeof UI_OPEN_SSH_URL; 78 | } 79 | export interface UIOpenHamburgerMenuAction { 80 | type: typeof UI_OPEN_HAMBURGER_MENU; 81 | } 82 | export interface UIWindowMinimizeAction { 83 | type: typeof UI_WINDOW_MINIMIZE; 84 | } 85 | export interface UIWindowCloseAction { 86 | type: typeof UI_WINDOW_CLOSE; 87 | } 88 | export interface UIEnterFullscreenAction { 89 | type: typeof UI_ENTER_FULLSCREEN; 90 | } 91 | export interface UILeaveFullscreenAction { 92 | type: typeof UI_LEAVE_FULLSCREEN; 93 | } 94 | export interface UIContextmenuOpenAction { 95 | type: typeof UI_CONTEXTMENU_OPEN; 96 | } 97 | export interface UICommandExecAction { 98 | type: typeof UI_COMMAND_EXEC; 99 | } 100 | 101 | export type UIActions = 102 | | UIFontSizeSetAction 103 | | UIFontSizeIncrAction 104 | | UIFontSizeDecrAction 105 | | UIFontSizeResetAction 106 | | UIFontSmoothingSetAction 107 | | UIMoveLeftAction 108 | | UIMoveRightAction 109 | | UIMoveToAction 110 | | UIMoveNextPaneAction 111 | | UIMovePrevPaneAction 112 | | UIShowPreferencesAction 113 | | UIWindowMoveAction 114 | | UIWindowMaximizeAction 115 | | UIWindowUnmaximizeAction 116 | | UIWindowGeometryChangedAction 117 | | UIOpenFileAction 118 | | UIOpenSshUrlAction 119 | | UIOpenHamburgerMenuAction 120 | | UIWindowMinimizeAction 121 | | UIWindowCloseAction 122 | | UIEnterFullscreenAction 123 | | UILeaveFullscreenAction 124 | | UIContextmenuOpenAction 125 | | UICommandExecAction; 126 | -------------------------------------------------------------------------------- /app/utils/cli-install.ts: -------------------------------------------------------------------------------- 1 | import pify from 'pify'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import Registry from 'winreg'; 5 | import notify from '../notify'; 6 | import {cliScriptPath, cliLinkPath} from '../config/paths'; 7 | 8 | const readlink = pify(fs.readlink); 9 | const symlink = pify(fs.symlink); 10 | 11 | const checkInstall = () => { 12 | return readlink(cliLinkPath) 13 | .then(link => link === cliScriptPath) 14 | .catch(err => { 15 | if (err.code === 'ENOENT') { 16 | return false; 17 | } 18 | throw err; 19 | }); 20 | }; 21 | 22 | const addSymlink = () => { 23 | return checkInstall().then(isInstalled => { 24 | if (isInstalled) { 25 | //eslint-disable-next-line no-console 26 | console.log('Hyper CLI already in PATH'); 27 | return Promise.resolve(); 28 | } 29 | //eslint-disable-next-line no-console 30 | console.log('Linking HyperCLI'); 31 | return symlink(cliScriptPath, cliLinkPath); 32 | }); 33 | }; 34 | 35 | const addBinToUserPath = () => { 36 | // Can't use pify because of param order of Registry.values callback 37 | return new Promise((resolve, reject) => { 38 | const envKey = new Registry({hive: 'HKCU', key: '\\Environment'}); 39 | envKey.values((err, items) => { 40 | if (err) { 41 | reject(err); 42 | return; 43 | } 44 | // C:\Users\\AppData\Local\hyper\app-\resources\bin 45 | const binPath = path.dirname(cliScriptPath); 46 | // C:\Users\\AppData\Local\hyper 47 | const basePath = path.resolve(binPath, '../../..'); 48 | 49 | const pathItem = items.find(item => item.name.toUpperCase() === 'PATH'); 50 | 51 | let newPathValue = binPath; 52 | const pathItemName = pathItem ? pathItem.name : 'PATH'; 53 | if (pathItem) { 54 | const pathParts = pathItem.value.split(';'); 55 | const existingPath = pathParts.find(pathPart => pathPart === binPath); 56 | if (existingPath) { 57 | //eslint-disable-next-line no-console 58 | console.log('Hyper CLI already in PATH'); 59 | resolve(); 60 | return; 61 | } 62 | 63 | // Because version is in path we need to remove old path if present and add current path 64 | newPathValue = pathParts 65 | .filter(pathPart => !pathPart.startsWith(basePath)) 66 | .concat([binPath]) 67 | .join(';'); 68 | } 69 | //eslint-disable-next-line no-console 70 | console.log('Adding HyperCLI path (registry)'); 71 | envKey.set(pathItemName, Registry.REG_SZ, newPathValue, error => { 72 | if (error) { 73 | reject(error); 74 | return; 75 | } 76 | resolve(); 77 | }); 78 | }); 79 | }); 80 | }; 81 | 82 | const logNotify = (withNotification: boolean, title: string, body: string, details?: any) => { 83 | console.log(title, body, details); 84 | withNotification && notify(title, body, details); 85 | }; 86 | 87 | export const installCLI = (withNotification: boolean) => { 88 | if (process.platform === 'win32') { 89 | addBinToUserPath() 90 | .then(() => 91 | logNotify( 92 | withNotification, 93 | 'Hyper CLI installed', 94 | 'You may need to restart your computer to complete this installation process.' 95 | ) 96 | ) 97 | .catch(err => 98 | logNotify(withNotification, 'Hyper CLI installation failed', `Failed to add Hyper CLI path to user PATH ${err}`) 99 | ); 100 | } else if (process.platform === 'darwin') { 101 | addSymlink() 102 | .then(() => logNotify(withNotification, 'Hyper CLI installed', `Symlink created at ${cliLinkPath}`)) 103 | .catch(err => { 104 | // 'EINVAL' is returned by readlink, 105 | // 'EEXIST' is returned by symlink 106 | const error = 107 | err.code === 'EEXIST' || err.code === 'EINVAL' 108 | ? `File already exists: ${cliLinkPath}` 109 | : `Symlink creation failed: ${err.code}`; 110 | 111 | //eslint-disable-next-line no-console 112 | console.error(err); 113 | logNotify(withNotification, 'Hyper CLI installation failed', error); 114 | }); 115 | } else { 116 | withNotification && 117 | notify('Hyper CLI installation', 'Command is added in PATH only at package installation. Please reinstall.'); 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /app/menus/menus/edit.ts: -------------------------------------------------------------------------------- 1 | import {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 2 | 3 | export default ( 4 | commandKeys: Record, 5 | execCommand: (command: string, focusedWindow?: BrowserWindow) => void 6 | ) => { 7 | const submenu: MenuItemConstructorOptions[] = [ 8 | { 9 | label: 'Undo', 10 | accelerator: commandKeys['editor:undo'], 11 | enabled: false 12 | }, 13 | { 14 | label: 'Redo', 15 | accelerator: commandKeys['editor:redo'], 16 | enabled: false 17 | }, 18 | { 19 | type: 'separator' 20 | }, 21 | { 22 | label: 'Cut', 23 | accelerator: commandKeys['editor:cut'], 24 | enabled: false 25 | }, 26 | { 27 | role: 'copy', 28 | command: 'editor:copy', 29 | accelerator: commandKeys['editor:copy'], 30 | registerAccelerator: true 31 | } as any, 32 | { 33 | role: 'paste', 34 | accelerator: commandKeys['editor:paste'] 35 | }, 36 | { 37 | label: 'Select All', 38 | accelerator: commandKeys['editor:selectAll'], 39 | click(item, focusedWindow) { 40 | execCommand('editor:selectAll', focusedWindow); 41 | } 42 | }, 43 | { 44 | type: 'separator' 45 | }, 46 | { 47 | label: 'Move to...', 48 | submenu: [ 49 | { 50 | label: 'Previous word', 51 | accelerator: commandKeys['editor:movePreviousWord'], 52 | click(item, focusedWindow) { 53 | execCommand('editor:movePreviousWord', focusedWindow); 54 | } 55 | }, 56 | { 57 | label: 'Next word', 58 | accelerator: commandKeys['editor:moveNextWord'], 59 | click(item, focusedWindow) { 60 | execCommand('editor:moveNextWord', focusedWindow); 61 | } 62 | }, 63 | { 64 | label: 'Line beginning', 65 | accelerator: commandKeys['editor:moveBeginningLine'], 66 | click(item, focusedWindow) { 67 | execCommand('editor:moveBeginningLine', focusedWindow); 68 | } 69 | }, 70 | { 71 | label: 'Line end', 72 | accelerator: commandKeys['editor:moveEndLine'], 73 | click(item, focusedWindow) { 74 | execCommand('editor:moveEndLine', focusedWindow); 75 | } 76 | } 77 | ] 78 | }, 79 | { 80 | label: 'Delete...', 81 | submenu: [ 82 | { 83 | label: 'Previous word', 84 | accelerator: commandKeys['editor:deletePreviousWord'], 85 | click(item, focusedWindow) { 86 | execCommand('editor:deletePreviousWord', focusedWindow); 87 | } 88 | }, 89 | { 90 | label: 'Next word', 91 | accelerator: commandKeys['editor:deleteNextWord'], 92 | click(item, focusedWindow) { 93 | execCommand('editor:deleteNextWord', focusedWindow); 94 | } 95 | }, 96 | { 97 | label: 'Line beginning', 98 | accelerator: commandKeys['editor:deleteBeginningLine'], 99 | click(item, focusedWindow) { 100 | execCommand('editor:deleteBeginningLine', focusedWindow); 101 | } 102 | }, 103 | { 104 | label: 'Line end', 105 | accelerator: commandKeys['editor:deleteEndLine'], 106 | click(item, focusedWindow) { 107 | execCommand('editor:deleteEndLine', focusedWindow); 108 | } 109 | } 110 | ] 111 | }, 112 | { 113 | type: 'separator' 114 | }, 115 | { 116 | label: 'Clear Buffer', 117 | accelerator: commandKeys['editor:clearBuffer'], 118 | click(item, focusedWindow) { 119 | execCommand('editor:clearBuffer', focusedWindow); 120 | } 121 | }, 122 | { 123 | label: 'Search', 124 | accelerator: commandKeys['editor:search'], 125 | click(item, focusedWindow) { 126 | execCommand('editor:search', focusedWindow); 127 | } 128 | } 129 | ]; 130 | 131 | if (process.platform !== 'darwin') { 132 | submenu.push( 133 | {type: 'separator'}, 134 | { 135 | label: 'Preferences...', 136 | accelerator: commandKeys['window:preferences'], 137 | click() { 138 | execCommand('window:preferences'); 139 | } 140 | } 141 | ); 142 | } 143 | 144 | return { 145 | label: 'Edit', 146 | submenu 147 | }; 148 | }; 149 | -------------------------------------------------------------------------------- /lib/components/style-sheet.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class StyleSheet extends React.PureComponent { 4 | render() { 5 | const {backgroundColor, fontFamily, foregroundColor, borderColor} = this.props; 6 | 7 | return ( 8 | 151 | ); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /lib/components/notifications.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {decorate} from '../utils/plugins'; 4 | 5 | import Notification_ from './notification'; 6 | 7 | const Notification = decorate(Notification_, 'Notification'); 8 | 9 | export default class Notifications extends React.PureComponent { 10 | render() { 11 | return ( 12 |
13 | {this.props.customChildrenBefore} 14 | {this.props.fontShowing && ( 15 | 23 | )} 24 | 25 | {this.props.resizeShowing && ( 26 | 34 | )} 35 | 36 | {this.props.messageShowing && ( 37 | 45 | {this.props.messageURL 46 | ? [ 47 | this.props.messageText, 48 | ' (', 49 | { 53 | window.require('electron').shell.openExternal(ev.target.href); 54 | ev.preventDefault(); 55 | }} 56 | href={this.props.messageURL} 57 | > 58 | more 59 | , 60 | ')' 61 | ] 62 | : null} 63 | 64 | )} 65 | 66 | {this.props.updateShowing && ( 67 | 75 | Version {this.props.updateVersion} ready. 76 | {this.props.updateNote && ` ${this.props.updateNote.trim().replace(/\.$/, '')}`} ( 77 | { 80 | window.require('electron').shell.openExternal(ev.target.href); 81 | ev.preventDefault(); 82 | }} 83 | href={`https://github.com/zeit/hyper/releases/tag/${this.props.updateVersion}`} 84 | > 85 | notes 86 | 87 | ).{' '} 88 | {this.props.updateCanInstall ? ( 89 | 97 | Restart 98 | 99 | ) : ( 100 | { 108 | window.require('electron').shell.openExternal(ev.target.href); 109 | ev.preventDefault(); 110 | }} 111 | href={this.props.updateReleaseUrl} 112 | > 113 | Download 114 | 115 | )} 116 | .{' '} 117 | 118 | )} 119 | {this.props.customChildren} 120 | 121 | 128 |
129 | ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /app/config/import.ts: -------------------------------------------------------------------------------- 1 | import {moveSync, copySync, existsSync, writeFileSync, readFileSync, lstatSync} from 'fs-extra'; 2 | import {sync as mkdirpSync} from 'mkdirp'; 3 | import {defaultCfg, cfgPath, legacyCfgPath, plugs, defaultPlatformKeyPath} from './paths'; 4 | import {_init, _extractDefault} from './init'; 5 | import notify from '../notify'; 6 | 7 | let defaultConfig: Record | undefined; 8 | 9 | const _write = (path: string, data: any) => { 10 | // This method will take text formatted as Unix line endings and transform it 11 | // to text formatted with DOS line endings. We do this because the default 12 | // text editor on Windows (notepad) doesn't Deal with LF files. Still. In 2017. 13 | const crlfify = (str: string) => { 14 | return str.replace(/\r?\n/g, '\r\n'); 15 | }; 16 | const format = process.platform === 'win32' ? crlfify(data.toString()) : data; 17 | writeFileSync(path, format, 'utf8'); 18 | }; 19 | 20 | // Saves a file as backup by appending '.backup' or '.backup2', '.backup3', etc. 21 | // so as to not override any existing files 22 | const saveAsBackup = (src: string) => { 23 | let attempt = 1; 24 | while (attempt < 100) { 25 | try { 26 | const backupPath = `${src}.backup${attempt === 1 ? '' : attempt}`; 27 | moveSync(src, backupPath); 28 | return backupPath; 29 | } catch (e) { 30 | if (e.code === 'EEXIST') { 31 | attempt++; 32 | } else { 33 | throw e; 34 | } 35 | } 36 | } 37 | throw new Error('Failed to create backup for config file. Too many backups'); 38 | }; 39 | 40 | // Migrate Hyper2 config to Hyper3 but only if the user hasn't manually 41 | // touched the new config and if the old config is not a symlink 42 | const migrateHyper2Config = () => { 43 | if (cfgPath === legacyCfgPath) { 44 | // No need to migrate 45 | return; 46 | } 47 | if (!existsSync(legacyCfgPath)) { 48 | // Already migrated or user never used Hyper 2 49 | return; 50 | } 51 | const existsNew = existsSync(cfgPath); 52 | if (lstatSync(legacyCfgPath).isSymbolicLink() || (existsNew && lstatSync(cfgPath).isSymbolicLink())) { 53 | // One of the files is a symlink, there could be a number of complications 54 | // in this case so let's avoid those and not do automatic migration 55 | return; 56 | } 57 | 58 | if (existsNew) { 59 | const cfg1 = readFileSync(defaultCfg, 'utf8').replace(/\r|\n/g, ''); 60 | const cfg2 = readFileSync(cfgPath, 'utf8').replace(/\r|\n/g, ''); 61 | const hasNewConfigBeenTouched = cfg1 !== cfg2; 62 | if (hasNewConfigBeenTouched) { 63 | // Assume the user has migrated manually but rename old config to .backup so 64 | // we don't keep trying to migrate on every launch 65 | const backupPath = saveAsBackup(legacyCfgPath); 66 | notify( 67 | 'Hyper 3', 68 | `Settings location has changed to ${cfgPath}.\nWe've backed up your old Hyper config to ${backupPath}` 69 | ); 70 | return; 71 | } 72 | } 73 | 74 | // Migrate 75 | copySync(legacyCfgPath, cfgPath); 76 | saveAsBackup(legacyCfgPath); 77 | 78 | notify( 79 | 'Hyper 3', 80 | `Settings location has changed to ${cfgPath}.\nWe've automatically migrated your existing config!\nPlease restart Hyper now` 81 | ); 82 | }; 83 | 84 | const _importConf = () => { 85 | // init plugin directories if not present 86 | mkdirpSync(plugs.base); 87 | mkdirpSync(plugs.local); 88 | 89 | try { 90 | migrateHyper2Config(); 91 | } catch (err) { 92 | //eslint-disable-next-line no-console 93 | console.error(err); 94 | } 95 | 96 | try { 97 | const defaultCfgRaw = readFileSync(defaultCfg, 'utf8'); 98 | const _defaultCfg = _extractDefault(defaultCfgRaw); 99 | // Importing platform specific keymap 100 | try { 101 | const content = readFileSync(defaultPlatformKeyPath(), 'utf8'); 102 | const mapping = JSON.parse(content) as Record; 103 | _defaultCfg.keymaps = mapping; 104 | } catch (err) { 105 | //eslint-disable-next-line no-console 106 | console.error(err); 107 | } 108 | 109 | // Import user config 110 | try { 111 | const userCfg = readFileSync(cfgPath, 'utf8'); 112 | return {userCfg, defaultCfg: _defaultCfg}; 113 | } catch (err) { 114 | _write(cfgPath, defaultCfgRaw); 115 | return {userCfg: defaultCfgRaw, defaultCfg: _defaultCfg}; 116 | } 117 | } catch (err) { 118 | //eslint-disable-next-line no-console 119 | console.log(err); 120 | } 121 | }; 122 | 123 | export const _import = () => { 124 | const imported = _importConf(); 125 | defaultConfig = imported?.defaultCfg; 126 | const result = _init(imported!); 127 | return result; 128 | }; 129 | 130 | export const getDefaultConfig = () => { 131 | if (!defaultConfig) { 132 | defaultConfig = _importConf()?.defaultCfg; 133 | } 134 | return defaultConfig; 135 | }; 136 | -------------------------------------------------------------------------------- /app/commands.ts: -------------------------------------------------------------------------------- 1 | import {app, Menu, BrowserWindow} from 'electron'; 2 | import {openConfig, getConfig} from './config'; 3 | import {updatePlugins} from './plugins'; 4 | import {installCLI} from './utils/cli-install'; 5 | 6 | const commands: Record void> = { 7 | 'window:new': () => { 8 | // If window is created on the same tick, it will consume event too 9 | setTimeout(app.createWindow, 0); 10 | }, 11 | 'tab:new': focusedWindow => { 12 | if (focusedWindow) { 13 | focusedWindow.rpc.emit('termgroup add req', {}); 14 | } else { 15 | setTimeout(app.createWindow, 0); 16 | } 17 | }, 18 | 'pane:splitRight': focusedWindow => { 19 | focusedWindow && focusedWindow.rpc.emit('split request vertical', {}); 20 | }, 21 | 'pane:splitDown': focusedWindow => { 22 | focusedWindow && focusedWindow.rpc.emit('split request horizontal', {}); 23 | }, 24 | 'pane:close': focusedWindow => { 25 | focusedWindow && focusedWindow.rpc.emit('termgroup close req'); 26 | }, 27 | 'window:preferences': () => { 28 | openConfig(); 29 | }, 30 | 'editor:clearBuffer': focusedWindow => { 31 | focusedWindow && focusedWindow.rpc.emit('session clear req'); 32 | }, 33 | 'editor:selectAll': focusedWindow => { 34 | focusedWindow && focusedWindow.rpc.emit('term selectAll'); 35 | }, 36 | 'plugins:update': () => { 37 | updatePlugins(); 38 | }, 39 | 'window:reload': focusedWindow => { 40 | focusedWindow && focusedWindow.rpc.emit('reload'); 41 | }, 42 | 'window:reloadFull': focusedWindow => { 43 | focusedWindow && focusedWindow.reload(); 44 | }, 45 | 'window:devtools': focusedWindow => { 46 | if (!focusedWindow) { 47 | return; 48 | } 49 | const webContents = focusedWindow.webContents; 50 | if (webContents.isDevToolsOpened()) { 51 | webContents.closeDevTools(); 52 | } else { 53 | webContents.openDevTools({mode: 'detach'}); 54 | } 55 | }, 56 | 'zoom:reset': focusedWindow => { 57 | focusedWindow && focusedWindow.rpc.emit('reset fontSize req'); 58 | }, 59 | 'zoom:in': focusedWindow => { 60 | focusedWindow && focusedWindow.rpc.emit('increase fontSize req'); 61 | }, 62 | 'zoom:out': focusedWindow => { 63 | focusedWindow && focusedWindow.rpc.emit('decrease fontSize req'); 64 | }, 65 | 'tab:prev': focusedWindow => { 66 | focusedWindow && focusedWindow.rpc.emit('move left req'); 67 | }, 68 | 'tab:next': focusedWindow => { 69 | focusedWindow && focusedWindow.rpc.emit('move right req'); 70 | }, 71 | 'pane:prev': focusedWindow => { 72 | focusedWindow && focusedWindow.rpc.emit('prev pane req'); 73 | }, 74 | 'pane:next': focusedWindow => { 75 | focusedWindow && focusedWindow.rpc.emit('next pane req'); 76 | }, 77 | 'editor:movePreviousWord': focusedWindow => { 78 | focusedWindow && focusedWindow.rpc.emit('session move word left req'); 79 | }, 80 | 'editor:moveNextWord': focusedWindow => { 81 | focusedWindow && focusedWindow.rpc.emit('session move word right req'); 82 | }, 83 | 'editor:moveBeginningLine': focusedWindow => { 84 | focusedWindow && focusedWindow.rpc.emit('session move line beginning req'); 85 | }, 86 | 'editor:moveEndLine': focusedWindow => { 87 | focusedWindow && focusedWindow.rpc.emit('session move line end req'); 88 | }, 89 | 'editor:deletePreviousWord': focusedWindow => { 90 | focusedWindow && focusedWindow.rpc.emit('session del word left req'); 91 | }, 92 | 'editor:deleteNextWord': focusedWindow => { 93 | focusedWindow && focusedWindow.rpc.emit('session del word right req'); 94 | }, 95 | 'editor:deleteBeginningLine': focusedWindow => { 96 | focusedWindow && focusedWindow.rpc.emit('session del line beginning req'); 97 | }, 98 | 'editor:deleteEndLine': focusedWindow => { 99 | focusedWindow && focusedWindow.rpc.emit('session del line end req'); 100 | }, 101 | 'editor:break': focusedWindow => { 102 | focusedWindow && focusedWindow.rpc.emit('session break req'); 103 | }, 104 | 'editor:search': focusedWindow => { 105 | focusedWindow && focusedWindow.rpc.emit('session search'); 106 | }, 107 | 'editor:search-close': focusedWindow => { 108 | focusedWindow && focusedWindow.rpc.emit('session search close'); 109 | }, 110 | 'cli:install': () => { 111 | installCLI(true); 112 | }, 113 | 'window:hamburgerMenu': () => { 114 | if (getConfig().showHamburgerMenu) { 115 | Menu.getApplicationMenu()!.popup({x: 15, y: 15}); 116 | } 117 | } 118 | }; 119 | 120 | //Special numeric command 121 | ([1, 2, 3, 4, 5, 6, 7, 8, 'last'] as const).forEach(cmdIndex => { 122 | const index = cmdIndex === 'last' ? cmdIndex : cmdIndex - 1; 123 | commands[`tab:jump:${cmdIndex}`] = focusedWindow => { 124 | focusedWindow && focusedWindow.rpc.emit('move jump req', index); 125 | }; 126 | }); 127 | 128 | export const execCommand = (command: string, focusedWindow?: BrowserWindow) => { 129 | const fn = commands[command]; 130 | if (fn) { 131 | fn(focusedWindow); 132 | } 133 | }; 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://assets.zeit.co/image/upload/v1549723846/repositories/hyper/hyper-3-repo-banner.png) 2 | 3 | [![macOS CI Status](https://circleci.com/gh/zeit/hyper.svg?style=shield)](https://circleci.com/gh/zeit/hyper) 4 | [![Windows CI status](https://ci.appveyor.com/api/projects/status/kqvb4oa772an58sc?svg=true)](https://ci.appveyor.com/project/zeit/hyper) 5 | [![Linux CI status](https://travis-ci.org/zeit/hyper.svg?branch=master)](https://travis-ci.org/zeit/hyper) 6 | [![Changelog #213](https://img.shields.io/badge/changelog-%23213-lightgrey.svg)](https://changelog.com/213) 7 | [![Join the community on Spectrum](https://withspectrum.github.io/badge/badge.svg)](https://spectrum.chat/zeit/hyper) 8 | 9 | For more details, head to: https://hyper.is 10 | 11 | ## Usage 12 | 13 | [Download the latest release!](https://hyper.is/#installation) 14 | 15 | ### Linux 16 | #### Arch and derivatives 17 | Hyper is available in the [AUR](https://aur.archlinux.org/packages/hyper/). Use an AUR package manager like [aurman](https://github.com/polygamma/aurman) 18 | 19 | ```sh 20 | aurman -S hyper 21 | ``` 22 | 23 | ### macOS 24 | 25 | Use [Homebrew Cask](https://brew.sh) to download the app by running these commands: 26 | 27 | ```bash 28 | brew update 29 | brew cask install hyper 30 | ``` 31 | 32 | ### Windows 33 | 34 | Use [chocolatey](https://chocolatey.org/) to install the app by running the following command (package information can be found [here](https://chocolatey.org/packages/hyper/)): 35 | 36 | ```bash 37 | choco install hyper 38 | ``` 39 | 40 | **Note:** The version available on [Homebrew Cask](https://brew.sh), [Chocolatey](https://chocolatey.org), [Snapcraft](https://snapcraft.io/store) or the [AUR](https://aur.archlinux.org) may not be the latest. Please consider downloading it from [here](https://hyper.is/#installation) if that's the case. 41 | 42 | ## Contribute 43 | 44 | Regardless of the platform you are working on, you will need to have Yarn installed. If you have never installed Yarn before, you can find out how at: https://yarnpkg.com/en/docs/install. 45 | 46 | 1. Install necessary packages: 47 | * Windows 48 | - Be sure to run `yarn global add windows-build-tools` from an elevated prompt (as an administrator) to install `windows-build-tools`. 49 | * macOS 50 | - Once you have installed Yarn, you can skip this section! 51 | * Linux (You can see [here](https://en.wikipedia.org/wiki/List_of_Linux_distributions) what your Linux is based on.) 52 | - RPM-based 53 | + `GraphicsMagick` 54 | + `libicns-utils` 55 | + `xz` (Installed by default on some distributions.) 56 | - Debian-based 57 | + `graphicsmagick` 58 | + `icnsutils` 59 | + `xz-utils` 60 | 2. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device 61 | 3. Install the dependencies: `yarn` 62 | 4. Build the code and watch for changes: `yarn run dev` 63 | 5. To run `hyper` 64 | * `yarn run app` from another terminal tab/window/pane 65 | * If you are using **Visual Studio Code**, select `Launch Hyper` in debugger configuration to launch a new Hyper instance with debugger attached. 66 | * If you interrupt `yarn run dev`, you'll need to relaunch it each time you want to test something. Webpack will watch changes and will rebuild renderer code when needed (and only what have changed). You'll just have to relaunch electron by using yarn run app or VSCode launch task. 67 | 68 | To make sure that your code works in the finished application, you can generate the binaries like this: 69 | 70 | ```bash 71 | yarn run dist 72 | ``` 73 | 74 | After that, you will see the binary in the `./dist` folder! 75 | 76 | #### Known issues that can happen during development 77 | 78 | ##### Error building `node-pty` 79 | 80 | If after building during development you get an alert dialog related to `node-pty` issues, 81 | make sure its build process is working correctly by running `yarn run rebuild-node-pty`. 82 | 83 | If you are on macOS, this typically is related to Xcode issues (like not having agreed 84 | to the Terms of Service by running `sudo xcodebuild` after a fresh Xcode installation). 85 | 86 | ##### Error with `c++` on macOS when running `yarn` 87 | 88 | If you are getting compiler errors when running `yarn` add the environment variable `export CXX=clang++` 89 | 90 | ##### Error with `codesign` on macOS when running `yarn run dist` 91 | 92 | If you have issues in the `codesign` step when running `yarn run dist` on macOS, you can temporarily disable code signing locally by setting 93 | `export CSC_IDENTITY_AUTO_DISCOVERY=false` for the current terminal session. 94 | 95 | ## Related Repositories 96 | 97 | - [Art](https://github.com/zeit/art/tree/master/hyper) 98 | - [Website](https://github.com/zeit/hyper-site) 99 | - [Sample Extension](https://github.com/zeit/hyperpower) 100 | - [Sample Theme](https://github.com/zeit/hyperyellow) 101 | - [Awesome Hyper](https://github.com/bnb/awesome-hyper) 102 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyper", 3 | "version": "3.1.0-canary.3", 4 | "repository": "zeit/hyper", 5 | "scripts": { 6 | "start": "echo 'please run `yarn run dev` in one tab and then `yarn run app` in another one'", 7 | "app": "electron target", 8 | "dev": "concurrently -n \"Webpack,TypeScript\" -c \"cyan.bold,blue.bold\" \"webpack -w\" \"tsc --build -v --pretty --watch --preserveWatchOutput\" -k", 9 | "build": "cross-env NODE_ENV=production webpack && tsc -b -v", 10 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx", 11 | "test": "yarn run lint && yarn run test:unit", 12 | "test:unit": "ava", 13 | "test:unit:watch": "yarn run test:unit -- --watch", 14 | "postinstall": "webpack --config-name hyper-app && electron-builder install-app-deps && yarn run rebuild-node-pty && cpy --cwd=target --parents \"node_modules/**/*\" \"../app/\"", 15 | "rebuild-node-pty": "electron-rebuild -f -w target/node_modules/node-pty -m target", 16 | "dist": "yarn run build && cross-env BABEL_ENV=production babel target/renderer/bundle.js --out-file target/renderer/bundle.js --no-comments --minified && electron-builder", 17 | "clean": "node ./bin/rimraf-standalone.js node_modules && node ./bin/rimraf-standalone.js ./app/node_modules && node ./bin/rimraf-standalone.js ./app/renderer" 18 | }, 19 | "license": "MIT", 20 | "author": { 21 | "name": "ZEIT, Inc.", 22 | "email": "team@zeit.co" 23 | }, 24 | "dependencies": { 25 | "args": "5.0.1", 26 | "chalk": "3.0.0", 27 | "color": "3.1.2", 28 | "columnify": "1.5.4", 29 | "css-loader": "3.4.2", 30 | "got": "10.5.5", 31 | "json-loader": "0.5.7", 32 | "mousetrap": "chabou/mousetrap#useCapture", 33 | "ms": "2.1.2", 34 | "open": "7.0.2", 35 | "ora": "4.0.3", 36 | "parse-url": "5.0.1", 37 | "php-escape-shell": "1.0.0", 38 | "react": "16.12.0", 39 | "react-deep-force-update": "2.1.3", 40 | "react-dom": "16.12.0", 41 | "react-redux": "7.1.3", 42 | "recast": "0.18.5", 43 | "redux": "4.0.5", 44 | "redux-thunk": "2.3.0", 45 | "reselect": "4.0.0", 46 | "seamless-immutable": "7.1.4", 47 | "semver": "7.1.2", 48 | "shebang-loader": "0.0.1", 49 | "styled-jsx": "3.2.4", 50 | "stylis": "3.5.4", 51 | "uuid": "3.4.0", 52 | "webpack-cli": "3.3.10", 53 | "xterm": "^4.4.0", 54 | "xterm-addon-fit": "^0.3.0", 55 | "xterm-addon-ligatures": "^0.2.1", 56 | "xterm-addon-search": "^0.4.0", 57 | "xterm-addon-web-links": "^0.2.1", 58 | "xterm-addon-webgl": "^0.5.0" 59 | }, 60 | "devDependencies": { 61 | "@ava/babel": "1.0.1", 62 | "@babel/cli": "7.8.4", 63 | "@babel/core": "7.8.4", 64 | "@babel/plugin-proposal-class-properties": "^7.8.3", 65 | "@babel/plugin-proposal-numeric-separator": "^7.8.3", 66 | "@babel/plugin-proposal-object-rest-spread": "^7.8.3", 67 | "@babel/plugin-proposal-optional-chaining": "7.8.3", 68 | "@babel/preset-react": "7.8.3", 69 | "@babel/preset-typescript": "7.8.3", 70 | "@types/args": "3.0.0", 71 | "@types/async-retry": "1.4.1", 72 | "@types/color": "3.0.1", 73 | "@types/columnify": "^1.5.0", 74 | "@types/copy-webpack-plugin": "5.0.0", 75 | "@types/electron-devtools-installer": "2.2.0", 76 | "@types/fs-extra": "8.0.1", 77 | "@types/mkdirp": "0.5.2", 78 | "@types/mousetrap": "^1.6.3", 79 | "@types/ms": "0.7.31", 80 | "@types/node": "^12.12.21", 81 | "@types/pify": "3.0.2", 82 | "@types/plist": "3.0.2", 83 | "@types/react": "^16.9.19", 84 | "@types/react-dom": "^16.9.5", 85 | "@types/react-redux": "^7.1.7", 86 | "@types/seamless-immutable": "7.1.11", 87 | "@types/styled-jsx": "2.2.8", 88 | "@types/terser-webpack-plugin": "2.2.0", 89 | "@types/uuid": "3.4.7", 90 | "@types/webdriverio": "^4.8.0", 91 | "@types/webpack": "4.41.6", 92 | "@types/winreg": "1.2.30", 93 | "@typescript-eslint/eslint-plugin": "2.19.0", 94 | "@typescript-eslint/parser": "2.19.0", 95 | "ava": "3.3.0", 96 | "babel-loader": "8.0.6", 97 | "concurrently": "5.1.0", 98 | "copy-webpack-plugin": "5.1.1", 99 | "cpy-cli": "^2.0.0", 100 | "cross-env": "7.0.0", 101 | "electron": "^7.1.9", 102 | "electron-builder": "22.3.2", 103 | "electron-builder-squirrel-windows": "22.3.3", 104 | "electron-devtools-installer": "2.2.4", 105 | "electron-rebuild": "1.10.0", 106 | "eslint": "6.8.0", 107 | "eslint-config-prettier": "6.10.0", 108 | "eslint-plugin-prettier": "3.1.2", 109 | "eslint-plugin-react": "7.18.3", 110 | "husky": "4.2.1", 111 | "inquirer": "7.0.4", 112 | "node-gyp": "6.1.0", 113 | "null-loader": "3.0.0", 114 | "plist": "3.0.1", 115 | "prettier": "1.19.1", 116 | "proxyquire": "2.1.3", 117 | "redux-devtools-extension": "2.13.8", 118 | "spectron": "10.0.1", 119 | "style-loader": "1.1.3", 120 | "terser": "4.6.3", 121 | "terser-webpack-plugin": "2.3.4", 122 | "ts-node": "8.6.2", 123 | "typescript": "3.7.5", 124 | "webpack": "4.41.5" 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/actions/sessions.ts: -------------------------------------------------------------------------------- 1 | import rpc from '../rpc'; 2 | import {keys} from '../utils/object'; 3 | import findBySession from '../utils/term-groups'; 4 | import { 5 | SESSION_ADD, 6 | SESSION_RESIZE, 7 | SESSION_REQUEST, 8 | SESSION_ADD_DATA, 9 | SESSION_PTY_DATA, 10 | SESSION_PTY_EXIT, 11 | SESSION_USER_EXIT, 12 | SESSION_SET_ACTIVE, 13 | SESSION_CLEAR_ACTIVE, 14 | SESSION_USER_DATA, 15 | SESSION_SET_XTERM_TITLE, 16 | SESSION_SEARCH, 17 | SESSION_SEARCH_CLOSE 18 | } from '../constants/sessions'; 19 | import {HyperState, session, HyperDispatch, HyperActions} from '../hyper'; 20 | 21 | export function addSession({uid, shell, pid, cols, rows, splitDirection, activeUid}: session) { 22 | return (dispatch: HyperDispatch, getState: () => HyperState) => { 23 | const {sessions} = getState(); 24 | const now = Date.now(); 25 | dispatch({ 26 | type: SESSION_ADD, 27 | uid, 28 | shell, 29 | pid, 30 | cols, 31 | rows, 32 | splitDirection, 33 | activeUid: activeUid ? activeUid : sessions.activeUid, 34 | now 35 | }); 36 | }; 37 | } 38 | 39 | export function requestSession() { 40 | return (dispatch: HyperDispatch, getState: () => HyperState) => { 41 | dispatch({ 42 | type: SESSION_REQUEST, 43 | effect: () => { 44 | const {ui} = getState(); 45 | // the cols and rows from preview session maybe not accurate. so remove. 46 | const {/*cols, rows,*/ cwd} = ui; 47 | rpc.emit('new', {cwd}); 48 | } 49 | }); 50 | }; 51 | } 52 | 53 | export function addSessionData(uid: string, data: any) { 54 | return (dispatch: HyperDispatch) => { 55 | dispatch({ 56 | type: SESSION_ADD_DATA, 57 | data, 58 | effect() { 59 | const now = Date.now(); 60 | dispatch({ 61 | type: SESSION_PTY_DATA, 62 | uid, 63 | data, 64 | now 65 | }); 66 | } 67 | }); 68 | }; 69 | } 70 | 71 | function createExitAction(type: typeof SESSION_USER_EXIT | typeof SESSION_PTY_EXIT) { 72 | return (uid: string) => (dispatch: HyperDispatch, getState: () => HyperState) => { 73 | return dispatch({ 74 | type, 75 | uid, 76 | effect() { 77 | if (type === SESSION_USER_EXIT) { 78 | rpc.emit('exit', {uid}); 79 | } 80 | 81 | const sessions = keys(getState().sessions.sessions); 82 | if (sessions.length === 0) { 83 | window.close(); 84 | } 85 | } 86 | } as HyperActions); 87 | }; 88 | } 89 | 90 | // we want to distinguish an exit 91 | // that's UI initiated vs pty initiated 92 | export const userExitSession = createExitAction(SESSION_USER_EXIT); 93 | export const ptyExitSession = createExitAction(SESSION_PTY_EXIT); 94 | 95 | export function setActiveSession(uid: string) { 96 | return (dispatch: HyperDispatch) => { 97 | dispatch({ 98 | type: SESSION_SET_ACTIVE, 99 | uid 100 | }); 101 | }; 102 | } 103 | 104 | export function clearActiveSession(): HyperActions { 105 | return { 106 | type: SESSION_CLEAR_ACTIVE 107 | }; 108 | } 109 | 110 | export function setSessionXtermTitle(uid: string, title: string): HyperActions { 111 | return { 112 | type: SESSION_SET_XTERM_TITLE, 113 | uid, 114 | title 115 | }; 116 | } 117 | 118 | export function resizeSession(uid: string, cols: number, rows: number) { 119 | return (dispatch: HyperDispatch, getState: () => HyperState) => { 120 | const {termGroups} = getState(); 121 | const group = findBySession(termGroups, uid)!; 122 | const isStandaloneTerm = !group.parentUid && !group.children.length; 123 | const now = Date.now(); 124 | dispatch({ 125 | type: SESSION_RESIZE, 126 | uid, 127 | cols, 128 | rows, 129 | isStandaloneTerm, 130 | now, 131 | effect() { 132 | rpc.emit('resize', {uid, cols, rows}); 133 | } 134 | }); 135 | }; 136 | } 137 | 138 | export function onSearch(uid?: string) { 139 | return (dispatch: HyperDispatch, getState: () => HyperState) => { 140 | const targetUid = uid || getState().sessions.activeUid!; 141 | dispatch({ 142 | type: SESSION_SEARCH, 143 | uid: targetUid 144 | }); 145 | }; 146 | } 147 | 148 | export function closeSearch(uid?: string) { 149 | return (dispatch: HyperDispatch, getState: () => HyperState) => { 150 | const targetUid = uid || getState().sessions.activeUid!; 151 | dispatch({ 152 | type: SESSION_SEARCH_CLOSE, 153 | uid: targetUid 154 | }); 155 | }; 156 | } 157 | 158 | export function sendSessionData(uid: string | null, data: any, escaped?: any) { 159 | return (dispatch: HyperDispatch, getState: () => HyperState) => { 160 | dispatch({ 161 | type: SESSION_USER_DATA, 162 | data, 163 | effect() { 164 | // If no uid is passed, data is sent to the active session. 165 | const targetUid = uid || getState().sessions.activeUid; 166 | 167 | rpc.emit('data', {uid: targetUid, data, escaped}); 168 | } 169 | }); 170 | }; 171 | } 172 | -------------------------------------------------------------------------------- /app/config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import notify from './notify'; 3 | import {_import, getDefaultConfig} from './config/import'; 4 | import _openConfig from './config/open'; 5 | import win from './config/windows'; 6 | import {cfgPath, cfgDir} from './config/paths'; 7 | import {getColorMap} from './utils/colors'; 8 | 9 | const watchers: any[] = []; 10 | let cfg: Record = {}; 11 | let _watcher: fs.FSWatcher; 12 | 13 | export const getDeprecatedCSS = (config: Record) => { 14 | const deprecated: string[] = []; 15 | const deprecatedCSS = ['x-screen', 'x-row', 'cursor-node', '::selection']; 16 | deprecatedCSS.forEach(css => { 17 | if ((config.css && config.css.includes(css)) || (config.termCSS && config.termCSS.includes(css))) { 18 | deprecated.push(css); 19 | } 20 | }); 21 | return deprecated; 22 | }; 23 | 24 | const checkDeprecatedConfig = () => { 25 | if (!cfg.config) { 26 | return; 27 | } 28 | const deprecated = getDeprecatedCSS(cfg.config); 29 | if (deprecated.length === 0) { 30 | return; 31 | } 32 | const deprecatedStr = deprecated.join(', '); 33 | notify('Configuration warning', `Your configuration uses some deprecated CSS classes (${deprecatedStr})`); 34 | }; 35 | 36 | const _watch = () => { 37 | if (_watcher) { 38 | return _watcher; 39 | } 40 | 41 | const onChange = () => { 42 | // Need to wait 100ms to ensure that write is complete 43 | setTimeout(() => { 44 | cfg = _import(); 45 | notify('Configuration updated', 'Hyper configuration reloaded!'); 46 | watchers.forEach(fn => fn()); 47 | checkDeprecatedConfig(); 48 | }, 100); 49 | }; 50 | 51 | // Windows 52 | if (process.platform === 'win32') { 53 | // watch for changes on config every 2s on Windows 54 | // https://github.com/zeit/hyper/pull/1772 55 | _watcher = fs.watchFile(cfgPath, {interval: 2000}, (curr, prev) => { 56 | if (!curr.mtime || curr.mtime.getTime() === 0) { 57 | //eslint-disable-next-line no-console 58 | console.error('error watching config'); 59 | } else if (curr.mtime.getTime() !== prev.mtime.getTime()) { 60 | onChange(); 61 | } 62 | }) as any; 63 | return; 64 | } 65 | // macOS/Linux 66 | function setWatcher() { 67 | try { 68 | _watcher = fs.watch(cfgPath, eventType => { 69 | if (eventType === 'rename') { 70 | _watcher.close(); 71 | // Ensure that new file has been written 72 | setTimeout(() => setWatcher(), 500); 73 | } 74 | }); 75 | } catch (e) { 76 | //eslint-disable-next-line no-console 77 | console.error('Failed to watch config file:', cfgPath, e); 78 | return; 79 | } 80 | _watcher.on('change', onChange); 81 | _watcher.on('error', error => { 82 | //eslint-disable-next-line no-console 83 | console.error('error watching config', error); 84 | }); 85 | } 86 | setWatcher(); 87 | }; 88 | 89 | export const subscribe = (fn: Function) => { 90 | watchers.push(fn); 91 | return () => { 92 | watchers.splice(watchers.indexOf(fn), 1); 93 | }; 94 | }; 95 | 96 | export const getConfigDir = () => { 97 | // expose config directory to load plugin from the right place 98 | return cfgDir; 99 | }; 100 | 101 | export const getConfig = () => { 102 | return cfg.config; 103 | }; 104 | 105 | export const openConfig = () => { 106 | return _openConfig(); 107 | }; 108 | 109 | export const getPlugins = (): {plugins: string[]; localPlugins: string[]} => { 110 | return { 111 | plugins: cfg.plugins, 112 | localPlugins: cfg.localPlugins 113 | }; 114 | }; 115 | 116 | export const getKeymaps = () => { 117 | return cfg.keymaps; 118 | }; 119 | 120 | export const setup = () => { 121 | cfg = _import(); 122 | _watch(); 123 | checkDeprecatedConfig(); 124 | }; 125 | 126 | export const getWin = win.get; 127 | export const winRecord = win.recordState; 128 | export const windowDefaults = win.defaults; 129 | 130 | export const fixConfigDefaults = (decoratedConfig: any) => { 131 | const defaultConfig = getDefaultConfig()?.config; 132 | decoratedConfig.colors = getColorMap(decoratedConfig.colors) || {}; 133 | // We must have default colors for xterm css. 134 | decoratedConfig.colors = Object.assign({}, defaultConfig.colors, decoratedConfig.colors); 135 | return decoratedConfig; 136 | }; 137 | 138 | export const htermConfigTranslate = (config: Record) => { 139 | const cssReplacements: Record = { 140 | 'x-screen x-row([ {.[])': '.xterm-rows > div$1', 141 | '.cursor-node([ {.[])': '.terminal-cursor$1', 142 | '::selection([ {.[])': '.terminal .xterm-selection div$1', 143 | 'x-screen a([ {.[])': '.terminal a$1', 144 | 'x-row a([ {.[])': '.terminal a$1' 145 | }; 146 | Object.keys(cssReplacements).forEach(pattern => { 147 | const searchvalue = new RegExp(pattern, 'g'); 148 | const newvalue = cssReplacements[pattern]; 149 | config.css = config.css && config.css.replace(searchvalue, newvalue); 150 | config.termCSS = config.termCSS && config.termCSS.replace(searchvalue, newvalue); 151 | }); 152 | return config; 153 | }; 154 | -------------------------------------------------------------------------------- /lib/components/term-group.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {decorate, getTermProps, getTermGroupProps} from '../utils/plugins'; 4 | import {resizeTermGroup} from '../actions/term-groups'; 5 | import Term_ from './term'; 6 | import SplitPane_ from './split-pane'; 7 | 8 | const Term = decorate(Term_, 'Term'); 9 | const SplitPane = decorate(SplitPane_, 'SplitPane'); 10 | 11 | class TermGroup_ extends React.PureComponent { 12 | constructor(props, context) { 13 | super(props, context); 14 | this.bound = new WeakMap(); 15 | this.termRefs = {}; 16 | } 17 | 18 | bind(fn, thisObj, uid) { 19 | if (!this.bound.has(fn)) { 20 | this.bound.set(fn, {}); 21 | } 22 | const map = this.bound.get(fn); 23 | if (!map[uid]) { 24 | map[uid] = fn.bind(thisObj, uid); 25 | } 26 | return map[uid]; 27 | } 28 | 29 | renderSplit(groups) { 30 | const [first, ...rest] = groups; 31 | if (rest.length === 0) { 32 | return first; 33 | } 34 | 35 | const direction = this.props.termGroup.direction.toLowerCase(); 36 | return ( 37 | 43 | {groups} 44 | 45 | ); 46 | } 47 | 48 | onTermRef = (uid, term) => { 49 | this.term = term; 50 | this.props.ref_(uid, term); 51 | }; 52 | 53 | renderTerm(uid) { 54 | const session = this.props.sessions[uid]; 55 | const termRef = this.props.terms[uid]; 56 | const props = getTermProps(uid, this.props, { 57 | isTermActive: uid === this.props.activeSession, 58 | term: termRef ? termRef.term : null, 59 | fitAddon: termRef ? termRef.fitAddon : null, 60 | searchAddon: termRef ? termRef.searchAddon : null, 61 | scrollback: this.props.scrollback, 62 | backgroundColor: this.props.backgroundColor, 63 | foregroundColor: this.props.foregroundColor, 64 | colors: this.props.colors, 65 | cursorBlink: this.props.cursorBlink, 66 | cursorShape: this.props.cursorShape, 67 | cursorColor: this.props.cursorColor, 68 | cursorAccentColor: this.props.cursorAccentColor, 69 | fontSize: this.props.fontSize, 70 | fontFamily: this.props.fontFamily, 71 | uiFontFamily: this.props.uiFontFamily, 72 | fontSmoothing: this.props.fontSmoothing, 73 | fontWeight: this.props.fontWeight, 74 | fontWeightBold: this.props.fontWeightBold, 75 | lineHeight: this.props.lineHeight, 76 | letterSpacing: this.props.letterSpacing, 77 | modifierKeys: this.props.modifierKeys, 78 | padding: this.props.padding, 79 | url: session.url, 80 | cleared: session.cleared, 81 | search: session.search, 82 | cols: session.cols, 83 | rows: session.rows, 84 | copyOnSelect: this.props.copyOnSelect, 85 | bell: this.props.bell, 86 | bellSoundURL: this.props.bellSoundURL, 87 | bellSound: this.props.bellSound, 88 | onActive: this.bind(this.props.onActive, null, uid), 89 | onResize: this.bind(this.props.onResize, null, uid), 90 | onTitle: this.bind(this.props.onTitle, null, uid), 91 | onData: this.bind(this.props.onData, null, uid), 92 | toggleSearch: this.bind(this.props.toggleSearch, null, uid), 93 | onContextMenu: this.bind(this.props.onContextMenu, null, uid), 94 | borderColor: this.props.borderColor, 95 | selectionColor: this.props.selectionColor, 96 | quickEdit: this.props.quickEdit, 97 | webGLRenderer: this.props.webGLRenderer, 98 | macOptionSelectionMode: this.props.macOptionSelectionMode, 99 | disableLigatures: this.props.disableLigatures, 100 | uid 101 | }); 102 | 103 | // This will create a new ref_ function for every render, 104 | // which is inefficient. Should maybe do something similar 105 | // to this.bind. 106 | return ; 107 | } 108 | 109 | render() { 110 | const {childGroups, termGroup} = this.props; 111 | if (termGroup.sessionUid) { 112 | return this.renderTerm(termGroup.sessionUid); 113 | } 114 | 115 | const groups = childGroups.map(child => { 116 | const props = getTermGroupProps( 117 | child.uid, 118 | this.props.parentProps, 119 | Object.assign({}, this.props, {termGroup: child}) 120 | ); 121 | 122 | return ; 123 | }); 124 | 125 | return this.renderSplit(groups); 126 | } 127 | } 128 | 129 | const TermGroup = connect( 130 | (state, ownProps) => ({ 131 | childGroups: ownProps.termGroup.children.map(uid => state.termGroups.termGroups[uid]) 132 | }), 133 | (dispatch, ownProps) => ({ 134 | onTermGroupResize(splitSizes) { 135 | dispatch(resizeTermGroup(ownProps.termGroup.uid, splitSizes)); 136 | } 137 | }), 138 | null, 139 | {forwardRef: true} 140 | )(TermGroup_); 141 | 142 | const DecoratedTermGroup = decorate(TermGroup, 'TermGroup'); 143 | 144 | export default TermGroup; 145 | -------------------------------------------------------------------------------- /cli/api.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import os from 'os'; 3 | import got from 'got'; 4 | import registryUrlModule from 'registry-url'; 5 | const registryUrl = registryUrlModule(); 6 | import pify from 'pify'; 7 | import * as recast from 'recast'; 8 | import path from 'path'; 9 | 10 | // If the user defines XDG_CONFIG_HOME they definitely want their config there, 11 | // otherwise use the home directory in linux/mac and userdata in windows 12 | const applicationDirectory = 13 | process.env.XDG_CONFIG_HOME !== undefined 14 | ? path.join(process.env.XDG_CONFIG_HOME, 'hyper') 15 | : process.platform == 'win32' 16 | ? path.join(process.env.APPDATA!, 'Hyper') 17 | : os.homedir(); 18 | 19 | const devConfigFileName = path.join(__dirname, `../.hyper.js`); 20 | 21 | const fileName = 22 | process.env.NODE_ENV !== 'production' && fs.existsSync(devConfigFileName) 23 | ? devConfigFileName 24 | : path.join(applicationDirectory, '.hyper.js'); 25 | 26 | /** 27 | * We need to make sure the file reading and parsing is lazy so that failure to 28 | * statically analyze the hyper configuration isn't fatal for all kinds of 29 | * subcommands. We can use memoization to make reading and parsing lazy. 30 | */ 31 | function memoize any>(fn: T): T { 32 | let hasResult = false; 33 | let result: any; 34 | return ((...args: any[]) => { 35 | if (!hasResult) { 36 | result = fn(...args); 37 | hasResult = true; 38 | } 39 | return result; 40 | }) as T; 41 | } 42 | 43 | const getFileContents = memoize(() => { 44 | try { 45 | return fs.readFileSync(fileName, 'utf8'); 46 | } catch (err) { 47 | if (err.code !== 'ENOENT') { 48 | // ENOENT === !exists() 49 | throw err; 50 | } 51 | } 52 | return null; 53 | }); 54 | 55 | const getParsedFile = memoize(() => recast.parse(getFileContents()!)); 56 | 57 | const getProperties = memoize(() => ((getParsedFile()?.program?.body as any[]) || []).map(obj => obj)); 58 | 59 | const getPluginsByKey = (key: string) => { 60 | const properties = getProperties(); 61 | for (let i = 0; i < properties.length; i++) { 62 | const rightProperties = Object.values(properties[i]?.expression?.right?.properties || {}); 63 | for (let j = 0; j < rightProperties.length; j++) { 64 | const plugin = rightProperties[j]; 65 | if (plugin?.key?.name === key) { 66 | return (plugin?.value?.elements as any[]) || []; 67 | } 68 | } 69 | } 70 | }; 71 | 72 | const getPlugins = memoize(() => { 73 | return getPluginsByKey('plugins'); 74 | }); 75 | 76 | const getLocalPlugins = memoize(() => { 77 | return getPluginsByKey('localPlugins'); 78 | }); 79 | 80 | function exists() { 81 | return getFileContents() !== undefined; 82 | } 83 | 84 | function isInstalled(plugin: string, locally?: boolean) { 85 | const array = (locally ? getLocalPlugins() : getPlugins()) || []; 86 | if (array && Array.isArray(array)) { 87 | return array.some(entry => entry.value === plugin); 88 | } 89 | return false; 90 | } 91 | 92 | function save() { 93 | return pify(fs.writeFile)(fileName, recast.print(getParsedFile()).code, 'utf8'); 94 | } 95 | 96 | function getPackageName(plugin: string) { 97 | const isScoped = plugin[0] === '@'; 98 | const nameWithoutVersion = plugin.split('#')[0]; 99 | 100 | if (isScoped) { 101 | return `@${nameWithoutVersion.split('@')[1].replace('/', '%2f')}`; 102 | } 103 | 104 | return nameWithoutVersion.split('@')[0]; 105 | } 106 | 107 | function existsOnNpm(plugin: string) { 108 | const name = getPackageName(plugin); 109 | return got 110 | .get(registryUrl + name.toLowerCase(), {timeout: 10000, responseType: 'json'}) 111 | .then(res => { 112 | if (!res.body.versions) { 113 | return Promise.reject(res); 114 | } else { 115 | return res; 116 | } 117 | }); 118 | } 119 | 120 | function install(plugin: string, locally?: boolean) { 121 | const array = (locally ? getLocalPlugins() : getPlugins()) || []; 122 | return existsOnNpm(plugin) 123 | .catch((err: any) => { 124 | const {statusCode} = err; 125 | if (statusCode && (statusCode === 404 || statusCode === 200)) { 126 | return Promise.reject(`${plugin} not found on npm`); 127 | } 128 | return Promise.reject(`${err.message}\nPlugin check failed. Check your internet connection or retry later.`); 129 | }) 130 | .then(() => { 131 | if (isInstalled(plugin, locally)) { 132 | return Promise.reject(`${plugin} is already installed`); 133 | } 134 | 135 | array.push(recast.types.builders.literal(plugin)); 136 | return save(); 137 | }); 138 | } 139 | 140 | function uninstall(plugin: string) { 141 | if (!isInstalled(plugin)) { 142 | return Promise.reject(`${plugin} is not installed`); 143 | } 144 | 145 | const index = getPlugins()!.findIndex(entry => entry.value === plugin); 146 | getPlugins()!.splice(index, 1); 147 | return save(); 148 | } 149 | 150 | function list() { 151 | if (Array.isArray(getPlugins())) { 152 | return getPlugins()! 153 | .map(plugin => plugin.value) 154 | .join('\n'); 155 | } 156 | return false; 157 | } 158 | 159 | export const configPath = fileName; 160 | export {exists, existsOnNpm, isInstalled, install, uninstall, list}; 161 | -------------------------------------------------------------------------------- /lib/components/tab.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Tab extends React.PureComponent { 4 | constructor() { 5 | super(); 6 | 7 | this.state = { 8 | hovered: false 9 | }; 10 | } 11 | 12 | handleHover = () => { 13 | this.setState({ 14 | hovered: true 15 | }); 16 | }; 17 | 18 | handleBlur = () => { 19 | this.setState({ 20 | hovered: false 21 | }); 22 | }; 23 | 24 | handleClick = event => { 25 | const isLeftClick = event.nativeEvent.which === 1; 26 | 27 | if (isLeftClick && !this.props.isActive) { 28 | this.props.onSelect(); 29 | } 30 | }; 31 | 32 | handleMouseUp = event => { 33 | const isMiddleClick = event.nativeEvent.which === 2; 34 | 35 | if (isMiddleClick) { 36 | this.props.onClose(); 37 | } 38 | }; 39 | 40 | render() { 41 | const {isActive, isFirst, isLast, borderColor, hasActivity} = this.props; 42 | const {hovered} = this.state; 43 | 44 | return ( 45 | 46 |
  • 55 | {this.props.customChildrenBefore} 56 | 61 | 62 | {this.props.text} 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | {this.props.customChildren} 71 |
  • 72 | 73 | 179 |
    180 | ); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /lib/containers/hyper.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger */ 2 | 3 | import React from 'react'; 4 | import Mousetrap from 'mousetrap'; 5 | 6 | import {connect} from '../utils/plugins'; 7 | import * as uiActions from '../actions/ui'; 8 | import {getRegisteredKeys, getCommandHandler, shouldPreventDefault} from '../command-registry'; 9 | import stylis from 'stylis'; 10 | 11 | import {HeaderContainer} from './header'; 12 | import TermsContainer from './terms'; 13 | import NotificationsContainer from './notifications'; 14 | import {HyperState} from '../hyper'; 15 | import {Dispatch} from 'redux'; 16 | 17 | const isMac = /Mac/.test(navigator.userAgent); 18 | 19 | class Hyper extends React.PureComponent { 20 | mousetrap!: MousetrapInstance; 21 | terms: any; 22 | constructor(props: any) { 23 | super(props); 24 | this.state = { 25 | lastConfigUpdate: 0 26 | }; 27 | } 28 | //TODO: Remove usage of legacy and soon deprecated lifecycle methods 29 | UNSAFE_componentWillReceiveProps(next: any) { 30 | if (this.props.backgroundColor !== next.backgroundColor) { 31 | // this can be removed when `setBackgroundColor` in electron 32 | // starts working again 33 | document.body.style.backgroundColor = next.backgroundColor; 34 | } 35 | const {lastConfigUpdate} = next; 36 | if (lastConfigUpdate && lastConfigUpdate !== this.state.lastConfigUpdate) { 37 | this.setState({lastConfigUpdate}); 38 | this.attachKeyListeners(); 39 | } 40 | } 41 | 42 | handleFocusActive = (uid: string) => { 43 | const term = this.terms.getTermByUid(uid); 44 | if (term) { 45 | term.focus(); 46 | } 47 | }; 48 | 49 | handleSelectAll = () => { 50 | const term = this.terms.getActiveTerm(); 51 | if (term) { 52 | term.selectAll(); 53 | } 54 | }; 55 | 56 | attachKeyListeners() { 57 | if (!this.mousetrap) { 58 | this.mousetrap = new (Mousetrap as any)(window, true); 59 | this.mousetrap.stopCallback = () => { 60 | // All events should be intercepted even if focus is in an input/textarea 61 | return false; 62 | }; 63 | } else { 64 | this.mousetrap.reset(); 65 | } 66 | 67 | const keys: Record = getRegisteredKeys(); 68 | Object.keys(keys).forEach(commandKeys => { 69 | this.mousetrap.bind( 70 | commandKeys, 71 | (e: any) => { 72 | const command = keys[commandKeys]; 73 | // We should tell to xterm that it should ignore this event. 74 | e.catched = true; 75 | this.props.execCommand(command, getCommandHandler(command), e); 76 | shouldPreventDefault(command) && e.preventDefault(); 77 | }, 78 | 'keydown' 79 | ); 80 | }); 81 | } 82 | 83 | componentDidMount() { 84 | this.attachKeyListeners(); 85 | window.rpc.on('term selectAll', this.handleSelectAll); 86 | } 87 | 88 | onTermsRef = (terms: any) => { 89 | this.terms = terms; 90 | window.focusActiveTerm = this.handleFocusActive; 91 | }; 92 | 93 | componentDidUpdate(prev: any) { 94 | if (prev.activeSession !== this.props.activeSession) { 95 | this.handleFocusActive(this.props.activeSession); 96 | } 97 | } 98 | 99 | componentWillUnmount() { 100 | document.body.style.backgroundColor = 'inherit'; 101 | this.mousetrap && this.mousetrap.reset(); 102 | } 103 | 104 | render() { 105 | const {isMac: isMac_, customCSS, uiFontFamily, borderColor, maximized, fullScreen} = this.props; 106 | const borderWidth = isMac_ ? '' : `${maximized ? '0' : '1'}px`; 107 | stylis.set({prefix: false}); 108 | return ( 109 |
    110 |
    114 | 115 | 116 | {this.props.customInnerChildren} 117 |
    118 | 119 | 120 | 121 | {this.props.customChildren} 122 | 123 | 139 | 140 | {/* 141 | Add custom CSS to Hyper. 142 | We add a scope to the customCSS so that it can get around the weighting applied by styled-jsx 143 | */} 144 |