├── .husky ├── .gitignore └── pre-push ├── .yarnrc ├── app ├── .yarnrc ├── static │ ├── icon.png │ └── icon96x96.png ├── tsconfig.json ├── utils │ ├── window-utils.ts │ ├── renderer-utils.ts │ ├── shell-fallback.ts │ ├── to-electron-background-color.ts │ ├── colors.ts │ ├── map-keys.ts │ └── system-context-menu.ts ├── patches │ └── node-pty+1.0.0.patch ├── config │ ├── windows.ts │ ├── import.ts │ ├── config-default.json │ ├── init.ts │ ├── open.ts │ └── paths.ts ├── notify.ts ├── index.html ├── plugins │ ├── extensions.ts │ └── install.ts ├── notifications.ts ├── package.json ├── menus │ ├── menus │ │ ├── tools.ts │ │ ├── darwin.ts │ │ ├── view.ts │ │ ├── window.ts │ │ ├── shell.ts │ │ ├── help.ts │ │ └── edit.ts │ └── menu.ts ├── auto-updater-linux.ts ├── keymaps │ ├── win32.json │ ├── linux.json │ └── darwin.json ├── ui │ └── contextmenu.ts ├── rpc.ts ├── updater.ts ├── config.ts └── commands.ts ├── lib ├── rpc.ts ├── utils │ ├── ipc.ts │ ├── notify.ts │ ├── term-groups.ts │ ├── object.ts │ ├── effects.ts │ ├── paste.ts │ ├── config.ts │ ├── file.ts │ ├── ipc-child-process.ts │ └── rpc.ts ├── actions │ ├── index.ts │ ├── config.ts │ ├── notifications.ts │ ├── updater.ts │ ├── header.ts │ └── sessions.ts ├── terms.ts ├── store │ ├── configure-store.ts │ ├── write-middleware.ts │ ├── configure-store.prod.ts │ └── configure-store.dev.ts ├── selectors.ts ├── reducers │ ├── index.ts │ └── sessions.ts ├── components │ ├── style-sheet.tsx │ ├── notification.tsx │ ├── tabs.tsx │ ├── notifications.tsx │ ├── new-tab.tsx │ └── tab.tsx ├── v8-snapshot-util.ts ├── command-registry.ts └── containers │ ├── notifications.ts │ ├── header.ts │ ├── terms.ts │ └── hyper.tsx ├── .gitattributes ├── ava.config.js ├── ava-e2e.config.js ├── typings ├── constants │ ├── index.d.ts │ ├── tabs.d.ts │ ├── updater.d.ts │ ├── config.d.ts │ ├── notifications.d.ts │ ├── term-groups.d.ts │ ├── sessions.d.ts │ └── ui.d.ts ├── ext-modules.d.ts ├── extend-electron.d.ts └── common.d.ts ├── test ├── testUtils │ └── is-hex-color.ts ├── unit │ ├── to-electron-background-color.test.ts │ ├── cli-api.test.ts │ └── window-utils.test.ts └── index.ts ├── .eslintignore ├── electron-builder-linux-ci.json ├── tsconfig.eslint.json ├── .editorconfig ├── tsconfig.json ├── .gitignore ├── babel.config.json ├── release.js ├── tsconfig.base.json ├── .github ├── pull_request_template.md ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── e2e_comment.yml │ └── codeql-analysis.yml ├── bin ├── notarize.js ├── snapshot-libs.js ├── cp-snapshot.js └── mk-snapshot.js ├── .vscode └── launch.json ├── LICENSE ├── assets └── icons.svg ├── cli └── api.ts ├── .eslintrc.json ├── electron-builder.json ├── README.md └── package.json /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmjs.org/" 2 | -------------------------------------------------------------------------------- /app/.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.npmjs.org/" 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn test 5 | -------------------------------------------------------------------------------- /app/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-hyper/canary/app/static/icon.png -------------------------------------------------------------------------------- /app/static/icon96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-hyper/canary/app/static/icon96x96.png -------------------------------------------------------------------------------- /lib/rpc.ts: -------------------------------------------------------------------------------- 1 | import RPC from './utils/rpc'; 2 | 3 | const rpc = new RPC(); 4 | 5 | export default rpc; 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | *.ts text eol=lf 4 | *.tsx text eol=lf 5 | bin/* linguist-vendored 6 | -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | files: ['test/unit/*'], 3 | extensions: ['ts'], 4 | require: ['ts-node/register/transpile-only'] 5 | }; 6 | -------------------------------------------------------------------------------- /ava-e2e.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | files: ['test/*'], 3 | extensions: ['ts'], 4 | require: ['ts-node/register/transpile-only'], 5 | timeout: '30s' 6 | }; 7 | -------------------------------------------------------------------------------- /typings/constants/index.d.ts: -------------------------------------------------------------------------------- 1 | export const INIT = 'INIT'; 2 | 3 | export interface InitAction { 4 | type: typeof INIT; 5 | } 6 | 7 | export type InitActions = InitAction; 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 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | app/renderer 3 | app/static 4 | app/bin 5 | app/dist 6 | app/node_modules 7 | app/typings 8 | assets 9 | website 10 | bin 11 | dist 12 | target 13 | cache 14 | schema.json -------------------------------------------------------------------------------- /electron-builder-linux-ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/electron-builder", 3 | "extends": "electron-builder.json", 4 | "afterSign": null, 5 | "npmRebuild": false 6 | } 7 | -------------------------------------------------------------------------------- /lib/utils/ipc.ts: -------------------------------------------------------------------------------- 1 | import {ipcRenderer as _ipc} from 'electron'; 2 | 3 | import type {IpcRendererWithCommands} from '../../typings/common'; 4 | 5 | export const ipcRenderer = _ipc as IpcRendererWithCommands; 6 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [ 4 | "./app/", 5 | "./lib/", 6 | "./test/", 7 | "./cli/", 8 | "./" 9 | ], 10 | "files": [ 11 | "./app/index.d.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /lib/utils/notify.ts: -------------------------------------------------------------------------------- 1 | /* eslint no-new:0 */ 2 | export default function notify(title: string, body: string, details: {error?: any} = {}) { 3 | console.log(`[Notification] ${title}: ${body}`); 4 | if (details.error) { 5 | console.error(details.error); 6 | } 7 | new Notification(title, {body}); 8 | } 9 | -------------------------------------------------------------------------------- /typings/constants/tabs.d.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 | -------------------------------------------------------------------------------- /lib/utils/term-groups.ts: -------------------------------------------------------------------------------- 1 | import type {ITermState} from '../../typings/hyper'; 2 | 3 | export default function findBySession(termGroupState: ITermState, sessionUid: string) { 4 | const {termGroups} = termGroupState; 5 | return Object.keys(termGroups) 6 | .map((uid) => termGroups[uid]) 7 | .find((group) => group.sessionUid === sessionUid); 8 | } 9 | -------------------------------------------------------------------------------- /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 | "./typings/" 12 | ], 13 | "references": [ 14 | { 15 | "path": "./app/tsconfig.json" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist 3 | app/renderer 4 | target 5 | bin/cli.* 6 | cache 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # logs 12 | npm-debug.log 13 | yarn-error.log 14 | 15 | # optional dev config file and plugins directory 16 | hyper.json 17 | schema.json 18 | plugins 19 | 20 | .DS_Store 21 | .vscode/* 22 | !.vscode/launch.json 23 | .idea 24 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "declarationDir": "../dist/tmp/appdts/", 5 | "outDir": "../target/", 6 | "noImplicitAny": false 7 | }, 8 | "include": [ 9 | "./**/*", 10 | "./package.json", 11 | "../typings/extend-electron.d.ts", 12 | "../typings/ext-modules.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /lib/actions/index.ts: -------------------------------------------------------------------------------- 1 | import {INIT} from '../../typings/constants'; 2 | import type {HyperDispatch} from '../../typings/hyper'; 3 | import rpc from '../rpc'; 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 type 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/store/configure-store.ts: -------------------------------------------------------------------------------- 1 | import configureStoreForDevelopment from './configure-store.dev'; 2 | import configureStoreForProduction from './configure-store.prod'; 3 | 4 | const configureStore = () => { 5 | if (process.env.NODE_ENV === 'production') { 6 | return configureStoreForProduction(); 7 | } 8 | 9 | return configureStoreForDevelopment(); 10 | }; 11 | export default configureStore; 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/selectors.ts: -------------------------------------------------------------------------------- 1 | import {createSelector} from 'reselect'; 2 | 3 | import type {HyperState} from '../typings/hyper'; 4 | 5 | const getTermGroups = ({termGroups}: Pick) => termGroups.termGroups; 6 | export const getRootGroups = createSelector(getTermGroups, (termGroups) => 7 | Object.keys(termGroups) 8 | .map((uid) => termGroups[uid]) 9 | .filter(({parentUid}) => !parentUid) 10 | ); 11 | -------------------------------------------------------------------------------- /lib/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | import type {Reducer} from 'redux'; 3 | 4 | import type {HyperActions, HyperState} from '../../typings/hyper'; 5 | 6 | import sessions from './sessions'; 7 | import termGroups from './term-groups'; 8 | import ui from './ui'; 9 | 10 | export default combineReducers({ 11 | ui, 12 | sessions, 13 | termGroups 14 | }) as Reducer; 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | console.error('Please specify a release summary!'); 16 | 17 | process.exit(1); 18 | } 19 | 20 | return `${intro}\n\n${markdown}`; 21 | }; 22 | -------------------------------------------------------------------------------- /typings/constants/updater.d.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 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": false, 5 | "composite": true, 6 | "esModuleInterop": true, 7 | "jsx": "react", 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "preserveConstEnums": true, 11 | "removeComments": false, 12 | "resolveJsonModule": true, 13 | "sourceMap": true, 14 | "strict": true, 15 | "target": "ES2022", 16 | "typeRoots": [ 17 | "./node_modules/@types" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /typings/constants/config.d.ts: -------------------------------------------------------------------------------- 1 | import type {configOptions} from '../config'; 2 | 3 | export const CONFIG_LOAD = 'CONFIG_LOAD'; 4 | export const CONFIG_RELOAD = 'CONFIG_RELOAD'; 5 | 6 | export interface ConfigLoadAction { 7 | type: typeof CONFIG_LOAD; 8 | config: configOptions; 9 | now?: number; 10 | } 11 | 12 | export interface ConfigReloadAction { 13 | type: typeof CONFIG_RELOAD; 14 | config: configOptions; 15 | now: number; 16 | } 17 | 18 | export type ConfigActions = ConfigLoadAction | ConfigReloadAction; 19 | -------------------------------------------------------------------------------- /app/utils/shell-fallback.ts: -------------------------------------------------------------------------------- 1 | export const getFallBackShellConfig = ( 2 | shell: string, 3 | shellArgs: string[], 4 | defaultShell: string, 5 | defaultShellArgs: string[] 6 | ): { 7 | shell: string; 8 | shellArgs: string[]; 9 | } | null => { 10 | if (shellArgs.length > 0) { 11 | return { 12 | shell, 13 | shellArgs: [] 14 | }; 15 | } 16 | 17 | if (shell != defaultShell) { 18 | return { 19 | shell: defaultShell, 20 | shellArgs: defaultShellArgs 21 | }; 22 | } 23 | 24 | return null; 25 | }; 26 | -------------------------------------------------------------------------------- /typings/constants/notifications.d.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 | -------------------------------------------------------------------------------- /app/patches/node-pty+1.0.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/node-pty/src/win/conpty.cc b/node_modules/node-pty/src/win/conpty.cc 2 | index 47af75c..884d542 100644 3 | --- a/node_modules/node-pty/src/win/conpty.cc 4 | +++ b/node_modules/node-pty/src/win/conpty.cc 5 | @@ -472,10 +472,6 @@ static NAN_METHOD(PtyKill) { 6 | } 7 | } 8 | 9 | - DisconnectNamedPipe(handle->hIn); 10 | - DisconnectNamedPipe(handle->hOut); 11 | - CloseHandle(handle->hIn); 12 | - CloseHandle(handle->hOut); 13 | CloseHandle(handle->hShell); 14 | } 15 | 16 | -------------------------------------------------------------------------------- /lib/actions/config.ts: -------------------------------------------------------------------------------- 1 | import type {configOptions} from '../../typings/config'; 2 | import {CONFIG_LOAD, CONFIG_RELOAD} from '../../typings/constants/config'; 3 | import type {HyperActions} from '../../typings/hyper'; 4 | 5 | export function loadConfig(config: configOptions): HyperActions { 6 | return { 7 | type: CONFIG_LOAD, 8 | config 9 | }; 10 | } 11 | 12 | export function reloadConfig(config: configOptions): HyperActions { 13 | const now = Date.now(); 14 | return { 15 | type: CONFIG_RELOAD, 16 | config, 17 | now 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /lib/actions/notifications.ts: -------------------------------------------------------------------------------- 1 | import {NOTIFICATION_MESSAGE, NOTIFICATION_DISMISS} from '../../typings/constants/notifications'; 2 | import type {HyperActions} from '../../typings/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 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /bin/notarize.js: -------------------------------------------------------------------------------- 1 | const { notarize } = require("@electron/notarize"); 2 | 3 | exports.default = async function notarizing(context) { 4 | const { electronPlatformName, appOutDir } = context; 5 | if (electronPlatformName !== "darwin" || !process.env.APPLE_ID || !process.env.APPLE_PASSWORD) { 6 | return; 7 | } 8 | 9 | const appName = context.packager.appInfo.productFilename; 10 | return await notarize({ 11 | appBundleId: "co.zeit.hyper", 12 | appPath: `${appOutDir}/${appName}.app`, 13 | appleId: process.env.APPLE_ID, 14 | appleIdPassword: process.env.APPLE_PASSWORD 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /typings/ext-modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'php-escape-shell' { 2 | export function php_escapeshellcmd(path: string): string; 3 | } 4 | 5 | declare module 'git-describe' { 6 | export function gitDescribe(...args: any[]): void; 7 | } 8 | 9 | declare module 'default-shell' { 10 | const val: string; 11 | export default val; 12 | } 13 | 14 | declare module 'sudo-prompt' { 15 | export function exec( 16 | cmd: string, 17 | options: {name?: string; icns?: string; env?: {[key: string]: string}}, 18 | callback: (error?: Error, stdout?: string | Buffer, stderr?: string | Buffer) => void 19 | ): void; 20 | } 21 | -------------------------------------------------------------------------------- /.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 '../../typings/constants/updater'; 2 | import type {HyperActions} from '../../typings/hyper'; 3 | import rpc from '../rpc'; 4 | 5 | export function installUpdate(): HyperActions { 6 | return { 7 | type: UPDATE_INSTALL, 8 | effect: () => { 9 | rpc.emit('quit and install'); 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 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: '11:00' 8 | open-pull-requests-limit: 30 9 | target-branch: canary 10 | versioning-strategy: increase 11 | - package-ecosystem: npm 12 | directory: "/app" 13 | schedule: 14 | interval: weekly 15 | time: '11:00' 16 | open-pull-requests-limit: 30 17 | target-branch: canary 18 | versioning-strategy: increase 19 | - package-ecosystem: github-actions 20 | directory: "/" 21 | schedule: 22 | interval: weekly 23 | time: '11:00' 24 | open-pull-requests-limit: 30 25 | target-branch: canary 26 | -------------------------------------------------------------------------------- /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 | const toElectronBackgroundColor = (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.hex().toString().slice(1)}`; 17 | }; 18 | 19 | export default toElectronBackgroundColor; 20 | -------------------------------------------------------------------------------- /app/config/windows.ts: -------------------------------------------------------------------------------- 1 | import type {BrowserWindow} from 'electron'; 2 | 3 | import Config from 'electron-store'; 4 | 5 | export const defaults = { 6 | windowPosition: [50, 50] as [number, number], 7 | windowSize: [540, 380] as [number, number] 8 | }; 9 | 10 | // local storage 11 | const cfg = new Config({defaults}); 12 | 13 | export function get() { 14 | const position = cfg.get('windowPosition', defaults.windowPosition); 15 | const size = cfg.get('windowSize', defaults.windowSize); 16 | return {position, size}; 17 | } 18 | export function recordState(win: BrowserWindow) { 19 | cfg.set('windowPosition', win.getPosition()); 20 | cfg.set('windowSize', win.getSize()); 21 | } 22 | -------------------------------------------------------------------------------- /app/notify.ts: -------------------------------------------------------------------------------- 1 | import {app, Notification} from 'electron'; 2 | 3 | import {icon} from './config/paths'; 4 | 5 | export default function notify(title: string, body = '', details: {error?: any} = {}) { 6 | console.log(`[Notification] ${title}: ${body}`); 7 | if (details.error) { 8 | console.error(details.error); 9 | } 10 | if (app.isReady()) { 11 | _createNotification(title, body); 12 | } else { 13 | app.on('ready', () => { 14 | _createNotification(title, body); 15 | }); 16 | } 17 | } 18 | 19 | const _createNotification = (title: string, body: string) => { 20 | new Notification({title, body, ...(process.platform === 'linux' && {icon})}).show(); 21 | }; 22 | -------------------------------------------------------------------------------- /lib/store/write-middleware.ts: -------------------------------------------------------------------------------- 1 | import type {Dispatch, Middleware} from 'redux'; 2 | 3 | import type {HyperActions, HyperState} from '../../typings/hyper'; 4 | import terms from '../terms'; 5 | 6 | // the only side effect we perform from middleware 7 | // is to write to the react term instance directly 8 | // to avoid a performance hit 9 | const writeMiddleware: Middleware<{}, HyperState, Dispatch> = () => (next) => (action: HyperActions) => { 10 | if (action.type === 'SESSION_PTY_DATA') { 11 | const term = terms[action.uid]; 12 | if (term) { 13 | term.term.write(action.data); 14 | } 15 | } 16 | next(action); 17 | }; 18 | 19 | export default writeMiddleware; 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea/feature for Hyper 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /lib/utils/object.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line eslint-comments/disable-enable-pair 2 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 3 | const valsCache = new WeakMap(); 4 | 5 | export function values(imm: Record) { 6 | if (!valsCache.has(imm)) { 7 | valsCache.set(imm, Object.values(imm)); 8 | } 9 | return valsCache.get(imm); 10 | } 11 | 12 | const keysCache = new WeakMap(); 13 | export function keys(imm: Record) { 14 | if (!keysCache.has(imm)) { 15 | keysCache.set(imm, Object.keys(imm)); 16 | } 17 | return keysCache.get(imm); 18 | } 19 | 20 | export const ObjectTypedKeys = (obj: T) => { 21 | return Object.keys(obj) as (keyof T)[]; 22 | }; 23 | -------------------------------------------------------------------------------- /lib/store/configure-store.prod.ts: -------------------------------------------------------------------------------- 1 | import {createStore, applyMiddleware} from 'redux'; 2 | import _thunk from 'redux-thunk'; 3 | import type {ThunkMiddleware} from 'redux-thunk'; 4 | 5 | import type {HyperState, HyperActions} from '../../typings/hyper'; 6 | import rootReducer from '../reducers/index'; 7 | import effects from '../utils/effects'; 8 | import * as plugins from '../utils/plugins'; 9 | 10 | import writeMiddleware from './write-middleware'; 11 | 12 | const thunk: ThunkMiddleware = _thunk; 13 | 14 | const configureStoreForProd = () => 15 | createStore(rootReducer, applyMiddleware(thunk, plugins.middleware, thunk, writeMiddleware, effects)); 16 | 17 | export default configureStoreForProd; 18 | -------------------------------------------------------------------------------- /lib/components/style-sheet.tsx: -------------------------------------------------------------------------------- 1 | import React, {forwardRef} from 'react'; 2 | 3 | import type {StyleSheetProps} from '../../typings/hyper'; 4 | 5 | const StyleSheet = forwardRef((props, ref) => { 6 | const {borderColor} = props; 7 | 8 | return ( 9 | 22 | ); 23 | }); 24 | 25 | StyleSheet.displayName = 'StyleSheet'; 26 | 27 | export default StyleSheet; 28 | -------------------------------------------------------------------------------- /lib/store/configure-store.dev.ts: -------------------------------------------------------------------------------- 1 | import {composeWithDevTools} from '@redux-devtools/extension'; 2 | import {createStore, applyMiddleware} from 'redux'; 3 | import _thunk from 'redux-thunk'; 4 | import type {ThunkMiddleware} from 'redux-thunk'; 5 | 6 | import type {HyperState, HyperActions} from '../../typings/hyper'; 7 | import rootReducer from '../reducers/index'; 8 | import effects from '../utils/effects'; 9 | import * as plugins from '../utils/plugins'; 10 | 11 | import writeMiddleware from './write-middleware'; 12 | 13 | const thunk: ThunkMiddleware = _thunk; 14 | 15 | const configureStoreForDevelopment = () => { 16 | const enhancer = composeWithDevTools(applyMiddleware(thunk, plugins.middleware, thunk, writeMiddleware, effects)); 17 | 18 | return createStore(rootReducer, enhancer); 19 | }; 20 | 21 | export default configureStoreForDevelopment; 22 | -------------------------------------------------------------------------------- /lib/v8-snapshot-util.ts: -------------------------------------------------------------------------------- 1 | if (typeof snapshotResult !== 'undefined') { 2 | const Module = __non_webpack_require__('module'); 3 | const originalLoad: (module: string, ...args: any[]) => any = Module._load; 4 | 5 | Module._load = function _load(module: string, ...args: unknown[]): NodeModule { 6 | let cachedModule = snapshotResult.customRequire.cache[module]; 7 | 8 | if (cachedModule) return cachedModule.exports; 9 | 10 | if (snapshotResult.customRequire.definitions[module]) { 11 | cachedModule = {exports: snapshotResult.customRequire(module)}; 12 | } else { 13 | cachedModule = {exports: originalLoad(module, ...args)}; 14 | } 15 | 16 | snapshotResult.customRequire.cache[module] = cachedModule; 17 | return cachedModule.exports; 18 | }; 19 | 20 | snapshotResult.setGlobals(global, process, window, document, console, global.require); 21 | } 22 | -------------------------------------------------------------------------------- /lib/utils/effects.ts: -------------------------------------------------------------------------------- 1 | import type {Dispatch, Middleware} from 'redux'; 2 | 3 | import type {HyperActions, HyperState} from '../../typings/hyper'; 4 | /** 5 | * Simple redux middleware that executes 6 | * the `effect` field if provided in an action 7 | * since this is preceded by the `plugins` 8 | * middleware. It allows authors to interrupt, 9 | * defer or add to existing side effects at will 10 | * as the result of an action being triggered. 11 | */ 12 | const effectsMiddleware: Middleware<{}, HyperState, Dispatch> = () => (next) => (action) => { 13 | const ret = next(action); 14 | if (action.effect) { 15 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 16 | action.effect(); 17 | delete action.effect; 18 | } 19 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 20 | return ret; 21 | }; 22 | export default effectsMiddleware; 23 | -------------------------------------------------------------------------------- /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 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 29 | return colors.reduce((result, color, index) => { 30 | if (index < colorList.length) { 31 | result[colorList[index]] = color; 32 | } 33 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 34 | return result; 35 | }, {}); 36 | }; 37 | -------------------------------------------------------------------------------- /lib/utils/paste.ts: -------------------------------------------------------------------------------- 1 | import {clipboard} from 'electron'; 2 | 3 | import plist from 'plist'; 4 | 5 | const getPath = (platform: string) => { 6 | switch (platform) { 7 | case 'darwin': { 8 | if (clipboard.has('NSFilenamesPboardType')) { 9 | // Parse plist file containing the path list of copied files 10 | const list = plist.parse(clipboard.read('NSFilenamesPboardType')) as plist.PlistArray; 11 | return "'" + list.join("' '") + "'"; 12 | } else { 13 | return null; 14 | } 15 | } 16 | case 'win32': { 17 | const filepath = clipboard.read('FileNameW'); 18 | return filepath.replace(new RegExp(String.fromCharCode(0), 'g'), ''); 19 | } 20 | // Linux already pastes full path 21 | default: 22 | return null; 23 | } 24 | }; 25 | 26 | export default function processClipboard() { 27 | return getPath(process.platform); 28 | } 29 | -------------------------------------------------------------------------------- /bin/snapshot-libs.js: -------------------------------------------------------------------------------- 1 | require('color-convert'); 2 | require('color-string'); 3 | require('columnify'); 4 | require('lodash'); 5 | require('ms'); 6 | require('normalize-url'); 7 | require('parse-url'); 8 | require('php-escape-shell'); 9 | require('plist'); 10 | require('redux-thunk'); 11 | require('redux'); 12 | require('reselect'); 13 | require('seamless-immutable'); 14 | require('stylis'); 15 | require('xterm-addon-unicode11'); 16 | // eslint-disable-next-line no-constant-condition 17 | if (false) { 18 | require('args'); 19 | require('mousetrap'); 20 | require('open'); 21 | require('react-dom'); 22 | require('react-redux'); 23 | require('react'); 24 | require('xterm-addon-fit'); 25 | require('xterm-addon-image'); 26 | require('xterm-addon-search'); 27 | require('xterm-addon-web-links'); 28 | require('xterm-addon-webgl'); 29 | require('xterm-addon-canvas'); 30 | require('xterm'); 31 | } 32 | -------------------------------------------------------------------------------- /lib/utils/config.ts: -------------------------------------------------------------------------------- 1 | import {require as remoteRequire, getCurrentWindow} from '@electron/remote'; 2 | // TODO: Should be updates to new async API https://medium.com/@nornagon/electrons-remote-module-considered-harmful-70d69500f31 3 | 4 | import {ipcRenderer} from './ipc'; 5 | 6 | const plugins = remoteRequire('./plugins') as typeof import('../../app/plugins'); 7 | 8 | Object.defineProperty(window, 'profileName', { 9 | get() { 10 | return getCurrentWindow().profileName; 11 | }, 12 | set() { 13 | throw new Error('profileName is readonly'); 14 | } 15 | }); 16 | 17 | export function getConfig() { 18 | return plugins.getDecoratedConfig(window.profileName); 19 | } 20 | 21 | export function subscribe(fn: (event: Electron.IpcRendererEvent, ...args: any[]) => void) { 22 | ipcRenderer.on('config change', fn); 23 | ipcRenderer.on('plugins change', fn); 24 | return () => { 25 | ipcRenderer.removeListener('config change', fn); 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /typings/constants/term-groups.d.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 enum 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 | -------------------------------------------------------------------------------- /typings/extend-electron.d.ts: -------------------------------------------------------------------------------- 1 | import type {Server} from '../app/rpc'; 2 | 3 | declare global { 4 | namespace Electron { 5 | interface App { 6 | config: typeof import('../app/config'); 7 | plugins: typeof import('../app/plugins'); 8 | getWindows: () => Set; 9 | getLastFocusedWindow: () => BrowserWindow | null; 10 | windowCallback?: (win: BrowserWindow) => void; 11 | createWindow: ( 12 | fn?: (win: BrowserWindow) => void, 13 | options?: {size?: [number, number]; position?: [number, number]}, 14 | profileName?: string 15 | ) => BrowserWindow; 16 | setVersion: (version: string) => void; 17 | } 18 | 19 | // type Server = import('./rpc').Server; 20 | interface BrowserWindow { 21 | uid: string; 22 | sessions: Map; 23 | focusTime: number; 24 | clean: () => void; 25 | rpc: Server; 26 | profileName: string; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /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 | 3 | import toElectronBackgroundColor from '../../app/utils/to-electron-background-color'; 4 | import {isHexColor} from '../testUtils/is-hex-color'; 5 | 6 | test('toElectronBackgroundColor', (t) => { 7 | t.false(false); 8 | }); 9 | 10 | test(`returns a color that's in hex`, (t) => { 11 | const hexColor = '#BADA55'; 12 | const rgbColor = 'rgb(0,0,0)'; 13 | const rgbaColor = 'rgb(0,0,0, 55)'; 14 | const hslColor = 'hsl(15, 100%, 50%)'; 15 | const hslaColor = 'hsl(15, 100%, 50%, 1)'; 16 | const colorKeyword = 'pink'; 17 | 18 | t.true(isHexColor(toElectronBackgroundColor(hexColor))); 19 | 20 | t.true(isHexColor(toElectronBackgroundColor(rgbColor))); 21 | 22 | t.true(isHexColor(toElectronBackgroundColor(rgbaColor))); 23 | 24 | t.true(isHexColor(toElectronBackgroundColor(hslColor))); 25 | 26 | t.true(isHexColor(toElectronBackgroundColor(hslaColor))); 27 | 28 | t.true(isHexColor(toElectronBackgroundColor(colorKeyword))); 29 | }); 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2018 Vercel, 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 | -------------------------------------------------------------------------------- /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 from 'fs'; 14 | import type {Stats} from 'fs'; 15 | 16 | export function isExecutable(fileStat: Stats): boolean { 17 | if (process.platform === 'win32') { 18 | return true; 19 | } 20 | 21 | return Boolean(fileStat.mode & 0o0001 || fileStat.mode & 0o0010 || fileStat.mode & 0o0100); 22 | } 23 | 24 | export function getBase64FileData(filePath: string): Promise { 25 | return new Promise((resolve): void => { 26 | return fs.readFile(filePath, (err, data) => { 27 | if (err) { 28 | // Gracefully fail with a warning 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 type {BrowserWindow} from 'electron'; 2 | 3 | import fetch from 'electron-fetch'; 4 | import ms from 'ms'; 5 | 6 | import {version} from './package.json'; 7 | 8 | const NEWS_URL = 'https://hyper-news.now.sh'; 9 | 10 | export default function fetchNotifications(win: BrowserWindow) { 11 | const {rpc} = win; 12 | const retry = (err?: Error) => { 13 | setTimeout(() => fetchNotifications(win), ms('30m')); 14 | if (err) { 15 | console.error('Notification messages fetch error', err.stack); 16 | } 17 | }; 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: {text: string; url: string; dismissable: boolean} | '' = data.message || ''; 28 | if (typeof message !== 'object' && message !== '') { 29 | throw new Error('Bad response'); 30 | } 31 | if (message === '') { 32 | console.log('No matching notification messages'); 33 | } else { 34 | rpc.emit('add notification', message); 35 | } 36 | 37 | retry(); 38 | }) 39 | .catch(retry); 40 | } 41 | -------------------------------------------------------------------------------- /app/plugins/install.ts: -------------------------------------------------------------------------------- 1 | import cp from 'child_process'; 2 | 3 | import ms from 'ms'; 4 | import queue from 'queue'; 5 | 6 | import {yarn, plugs} from '../config/paths'; 7 | 8 | export const install = (fn: (err: string | null) => void) => { 9 | const spawnQueue = queue({concurrency: 1}); 10 | function yarnFn(args: string[], cb: (err: string | null) => void) { 11 | const env = { 12 | NODE_ENV: 'production', 13 | ELECTRON_RUN_AS_NODE: 'true' 14 | }; 15 | spawnQueue.push((end) => { 16 | const cmd = [process.execPath, yarn].concat(args).join(' '); 17 | console.log('Launching yarn:', cmd); 18 | 19 | cp.execFile( 20 | process.execPath, 21 | [yarn].concat(args), 22 | { 23 | cwd: plugs.base, 24 | env, 25 | timeout: ms('5m'), 26 | maxBuffer: 1024 * 1024 27 | }, 28 | (err, stdout, stderr) => { 29 | if (err) { 30 | cb(stderr); 31 | } else { 32 | cb(null); 33 | } 34 | end?.(); 35 | spawnQueue.start(); 36 | } 37 | ); 38 | }); 39 | 40 | spawnQueue.start(); 41 | } 42 | 43 | yarnFn(['install', '--no-emoji', '--no-lockfile', '--cache-folder', plugs.cache], (err) => { 44 | if (err) { 45 | return fn(err); 46 | } 47 | fn(null); 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hyper", 3 | "productName": "Hyper", 4 | "description": "A terminal built on web technologies", 5 | "version": "4.0.0-canary.5", 6 | "license": "MIT", 7 | "author": { 8 | "name": "ZEIT, Inc.", 9 | "email": "team@zeit.co" 10 | }, 11 | "repository": "zeit/hyper", 12 | "scripts": { 13 | "postinstall": "npx patch-package" 14 | }, 15 | "dependencies": { 16 | "@babel/parser": "7.24.4", 17 | "@electron/remote": "2.1.2", 18 | "ast-types": "^0.16.1", 19 | "async-retry": "1.3.3", 20 | "chokidar": "^3.6.0", 21 | "color": "4.2.3", 22 | "default-shell": "1.0.1", 23 | "electron-devtools-installer": "3.2.0", 24 | "electron-fetch": "1.9.1", 25 | "electron-is-dev": "2.0.0", 26 | "electron-store": "8.2.0", 27 | "fs-extra": "11.2.0", 28 | "git-describe": "4.1.1", 29 | "lodash": "4.17.21", 30 | "ms": "2.1.3", 31 | "native-process-working-directory": "^1.0.2", 32 | "node-pty": "1.0.0", 33 | "os-locale": "5.0.0", 34 | "parse-url": "8.1.0", 35 | "queue": "6.0.2", 36 | "react": "18.2.0", 37 | "react-dom": "18.2.0", 38 | "recast": "0.23.6", 39 | "semver": "7.6.0", 40 | "shell-env": "3.0.1", 41 | "sudo-prompt": "^9.2.1", 42 | "uuid": "9.0.1" 43 | }, 44 | "optionalDependencies": { 45 | "native-reg": "1.1.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/menus/menus/tools.ts: -------------------------------------------------------------------------------- 1 | import type {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 2 | 3 | const toolsMenu = ( 4 | commands: Record, 5 | execCommand: (command: string, focusedWindow?: BrowserWindow) => void 6 | ): MenuItemConstructorOptions => { 7 | return { 8 | label: 'Tools', 9 | submenu: [ 10 | { 11 | label: 'Update plugins', 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 | ...(process.platform === 'win32' 27 | ? [ 28 | { 29 | label: 'Add Hyper to system context menu', 30 | click() { 31 | execCommand('systemContextMenu:add'); 32 | } 33 | }, 34 | { 35 | label: 'Remove Hyper from system context menu', 36 | click() { 37 | execCommand('systemContextMenu:remove'); 38 | } 39 | }, 40 | { 41 | type: 'separator' 42 | } 43 | ] 44 | : []) 45 | ] 46 | }; 47 | }; 48 | 49 | export default toolsMenu; 50 | -------------------------------------------------------------------------------- /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} from 'electron'; 4 | import type {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 5 | 6 | const darwinMenu = ( 7 | commandKeys: Record, 8 | execCommand: (command: string, focusedWindow?: BrowserWindow) => void, 9 | showAbout: () => void 10 | ): MenuItemConstructorOptions => { 11 | return { 12 | label: `${app.name}`, 13 | submenu: [ 14 | { 15 | label: 'About Hyper', 16 | click() { 17 | showAbout(); 18 | } 19 | }, 20 | { 21 | type: 'separator' 22 | }, 23 | { 24 | label: 'Preferences...', 25 | accelerator: commandKeys['window:preferences'], 26 | click() { 27 | execCommand('window:preferences'); 28 | } 29 | }, 30 | { 31 | type: 'separator' 32 | }, 33 | { 34 | role: 'services', 35 | submenu: [] 36 | }, 37 | { 38 | type: 'separator' 39 | }, 40 | { 41 | role: 'hide' 42 | }, 43 | { 44 | role: 'hideOthers' 45 | }, 46 | { 47 | role: 'unhide' 48 | }, 49 | { 50 | type: 'separator' 51 | }, 52 | { 53 | role: 'quit' 54 | } 55 | ] 56 | }; 57 | }; 58 | 59 | export default darwinMenu; 60 | -------------------------------------------------------------------------------- /test/unit/cli-api.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable eslint-comments/disable-enable-pair */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 4 | import test from 'ava'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-var-requires 7 | const proxyquire = require('proxyquire').noCallThru(); 8 | 9 | test('existsOnNpm() builds the url for non-scoped packages', (t) => { 10 | let getUrl: string; 11 | const {existsOnNpm} = proxyquire('../../cli/api', { 12 | got: { 13 | get(url: string) { 14 | getUrl = url; 15 | return Promise.resolve({ 16 | body: { 17 | versions: [] 18 | } 19 | }); 20 | } 21 | }, 22 | 'registry-url': () => 'https://registry.npmjs.org/' 23 | }); 24 | 25 | return existsOnNpm('pkg').then(() => { 26 | t.is(getUrl, 'https://registry.npmjs.org/pkg'); 27 | }); 28 | }); 29 | 30 | test('existsOnNpm() builds the url for scoped packages', (t) => { 31 | let getUrl: string; 32 | const {existsOnNpm} = proxyquire('../../cli/api', { 33 | got: { 34 | get(url: string) { 35 | getUrl = url; 36 | return Promise.resolve({ 37 | body: { 38 | versions: [] 39 | } 40 | }); 41 | } 42 | }, 43 | 'registry-url': () => 'https://registry.npmjs.org/' 44 | }); 45 | 46 | return existsOnNpm('@scope/pkg').then(() => { 47 | t.is(getUrl, 'https://registry.npmjs.org/@scope%2fpkg'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help Hyper improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 17 | 18 | 19 | - [ ] I am on the [latest](https://github.com/vercel/hyper/releases/latest) Hyper.app version 20 | - [ ] I have searched the [issues](https://github.com/vercel/hyper/issues) of this repo and believe that this is not a duplicate 21 | 22 | 26 | 27 | - **OS version and name**: 28 | - **Hyper.app version**: 29 | - **Link of a [Gist](https://gist.github.com/) with the contents of your hyper.json**: 30 | - **Relevant information from devtools** _(CMD+ALT+I on macOS, CTRL+SHIFT+I elsewhere)_: 31 | - **The issue is reproducible in vanilla Hyper.app**: 32 | 33 | ## Issue 34 | 35 | -------------------------------------------------------------------------------- /lib/command-registry.ts: -------------------------------------------------------------------------------- 1 | import type {HyperDispatch} from '../typings/hyper'; 2 | 3 | import {closeSearch} from './actions/sessions'; 4 | import {ipcRenderer} from './utils/ipc'; 5 | 6 | let commands: Record void> = { 7 | 'editor:search-close': (e, dispatch) => { 8 | dispatch(closeSearch(undefined, e)); 9 | window.focusActiveTerm(); 10 | } 11 | }; 12 | 13 | export const getRegisteredKeys = async () => { 14 | const keymaps = await ipcRenderer.invoke('getDecoratedKeymaps'); 15 | 16 | return Object.keys(keymaps).reduce((result: Record, actionName) => { 17 | const commandKeys = keymaps[actionName]; 18 | commandKeys.forEach((shortcut) => { 19 | result[shortcut] = actionName; 20 | }); 21 | return result; 22 | }, {}); 23 | }; 24 | 25 | export const registerCommandHandlers = (cmds: typeof commands) => { 26 | if (!cmds) { 27 | return; 28 | } 29 | 30 | commands = Object.assign(commands, cmds); 31 | }; 32 | 33 | export const getCommandHandler = (command: string) => { 34 | return commands[command]; 35 | }; 36 | 37 | // Some commands are directly excuted by Electron menuItem role. 38 | // They should not be prevented to reach Electron. 39 | const roleCommands = [ 40 | 'window:close', 41 | 'editor:undo', 42 | 'editor:redo', 43 | 'editor:cut', 44 | 'editor:copy', 45 | 'editor:paste', 46 | 'editor:selectAll', 47 | 'window:minimize', 48 | 'window:zoom', 49 | 'window:toggleFullScreen' 50 | ]; 51 | 52 | export const shouldPreventDefault = (command: string) => !roleCommands.includes(command); 53 | -------------------------------------------------------------------------------- /app/auto-updater-linux.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | 3 | import fetch from 'electron-fetch'; 4 | 5 | class AutoUpdater extends EventEmitter implements Electron.AutoUpdater { 6 | updateURL!: string; 7 | quitAndInstall() { 8 | this.emitError('QuitAndInstall unimplemented'); 9 | } 10 | getFeedURL() { 11 | return this.updateURL; 12 | } 13 | 14 | setFeedURL(options: Electron.FeedURLOptions) { 15 | this.updateURL = options.url; 16 | } 17 | 18 | checkForUpdates() { 19 | if (!this.updateURL) { 20 | return this.emitError('Update URL is not set'); 21 | } 22 | this.emit('checking-for-update'); 23 | 24 | fetch(this.updateURL) 25 | .then((res) => { 26 | if (res.status === 204) { 27 | this.emit('update-not-available'); 28 | return; 29 | } 30 | return res.json().then(({name, notes, pub_date}: {name: string; notes: string; pub_date: string}) => { 31 | // Only name is mandatory, needed to construct release URL. 32 | if (!name) { 33 | throw new Error('Malformed server response: release name is missing.'); 34 | } 35 | const date = pub_date ? new Date(pub_date) : new Date(); 36 | this.emit('update-available', {}, notes, name, date); 37 | }); 38 | }) 39 | .catch(this.emitError.bind(this)); 40 | } 41 | 42 | emitError(error: string | Error) { 43 | if (typeof error === 'string') { 44 | error = new Error(error); 45 | } 46 | this.emit('error', error); 47 | } 48 | } 49 | 50 | const autoUpdaterLinux = new AutoUpdater(); 51 | 52 | export default autoUpdaterLinux; 53 | -------------------------------------------------------------------------------- /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+f", 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:next": [ 20 | "ctrl+tab" 21 | ], 22 | "tab:prev": [ 23 | "ctrl+shift+tab" 24 | ], 25 | "tab:jump:prefix": "ctrl", 26 | "pane:next": "ctrl+pageup", 27 | "pane:prev": "ctrl+pagedown", 28 | "pane:splitRight": "ctrl+shift+d", 29 | "pane:splitDown": "ctrl+shift+e", 30 | "pane:close": "ctrl+shift+w", 31 | "editor:undo": "ctrl+shift+z", 32 | "editor:redo": "ctrl+shift+y", 33 | "editor:cut": "ctrl+shift+x", 34 | "editor:copy": "ctrl+shift+c", 35 | "editor:paste": "ctrl+shift+v", 36 | "editor:selectAll": "ctrl+shift+a", 37 | "editor:search": "ctrl+shift+f", 38 | "editor:search-close": "esc", 39 | "editor:movePreviousWord": "", 40 | "editor:moveNextWord": "", 41 | "editor:moveBeginningLine": "Home", 42 | "editor:moveEndLine": "End", 43 | "editor:deletePreviousWord": "ctrl+backspace", 44 | "editor:deleteNextWord": "ctrl+del", 45 | "editor:deleteBeginningLine": "ctrl+home", 46 | "editor:deleteEndLine": "ctrl+end", 47 | "editor:clearBuffer": "ctrl+shift+k", 48 | "editor:break": "ctrl+c", 49 | "plugins:update": "ctrl+shift+u" 50 | } 51 | -------------------------------------------------------------------------------- /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 | const mapKeys = (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 | console.warn('Your config use deprecated `cmd` in key combination. Please use `command` instead.'); 28 | newShortcut = newShortcut.replace('cmd', 'command'); 29 | } 30 | fixedShortcuts.push(newShortcut); 31 | }); 32 | 33 | if (command.endsWith(':prefix')) { 34 | return Object.assign(keymap, generatePrefixedCommand(command, fixedShortcuts)); 35 | } 36 | 37 | keymap[command] = fixedShortcuts; 38 | 39 | return keymap; 40 | }, {}); 41 | }; 42 | 43 | export default mapKeys; 44 | -------------------------------------------------------------------------------- /app/ui/contextmenu.ts: -------------------------------------------------------------------------------- 1 | import type {MenuItemConstructorOptions, BrowserWindow} from 'electron'; 2 | 3 | import {execCommand} from '../commands'; 4 | import {getProfiles} from '../config'; 5 | import editMenu from '../menus/menus/edit'; 6 | import shellMenu from '../menus/menus/shell'; 7 | import {getDecoratedKeymaps} from '../plugins'; 8 | 9 | const separator: MenuItemConstructorOptions = {type: 'separator'}; 10 | 11 | const getCommandKeys = (keymaps: Record): Record => 12 | Object.keys(keymaps).reduce((commandKeys: Record, command) => { 13 | return Object.assign(commandKeys, { 14 | [command]: keymaps[command][0] 15 | }); 16 | }, {}); 17 | 18 | // only display cut/copy when there's a cursor selection 19 | const filterCutCopy = (selection: string, menuItem: MenuItemConstructorOptions) => { 20 | if (/^cut$|^copy$/.test(menuItem.role!) && !selection) { 21 | return; 22 | } 23 | return menuItem; 24 | }; 25 | 26 | const contextMenuTemplate = ( 27 | createWindow: (fn?: (win: BrowserWindow) => void, options?: Record) => BrowserWindow, 28 | selection: string 29 | ) => { 30 | const commandKeys = getCommandKeys(getDecoratedKeymaps()); 31 | const _shell = shellMenu( 32 | commandKeys, 33 | execCommand, 34 | getProfiles().map((p) => p.name) 35 | ).submenu as MenuItemConstructorOptions[]; 36 | const _edit = editMenu(commandKeys, execCommand).submenu.filter(filterCutCopy.bind(null, selection)); 37 | return _edit 38 | .concat(separator, _shell) 39 | .filter((menuItem) => !Object.prototype.hasOwnProperty.call(menuItem, 'enabled') || menuItem.enabled); 40 | }; 41 | 42 | export default contextMenuTemplate; 43 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | // Native 2 | import path from 'path'; 3 | 4 | // Packages 5 | import test from 'ava'; 6 | import fs from 'fs-extra'; 7 | import {_electron} from 'playwright'; 8 | import type {ElectronApplication} from 'playwright'; 9 | 10 | let app: ElectronApplication; 11 | 12 | test.before(async () => { 13 | let pathToBinary; 14 | 15 | switch (process.platform) { 16 | case 'linux': 17 | pathToBinary = path.join(__dirname, '../dist/linux-unpacked/hyper'); 18 | break; 19 | 20 | case 'darwin': 21 | pathToBinary = path.join(__dirname, '../dist/mac/Hyper.app/Contents/MacOS/Hyper'); 22 | break; 23 | 24 | case 'win32': 25 | pathToBinary = path.join(__dirname, '../dist/win-unpacked/Hyper.exe'); 26 | break; 27 | 28 | default: 29 | throw new Error('Path to the built binary needs to be defined for this platform in test/index.js'); 30 | } 31 | 32 | app = await _electron.launch({ 33 | executablePath: pathToBinary 34 | }); 35 | await app.firstWindow(); 36 | await new Promise((resolve) => setTimeout(resolve, 5000)); 37 | }); 38 | 39 | test.after(async () => { 40 | await app 41 | .evaluate(({BrowserWindow}) => 42 | BrowserWindow.getFocusedWindow() 43 | ?.capturePage() 44 | .then((img) => img.toPNG().toString('base64')) 45 | ) 46 | .then((img) => Buffer.from(img || '', 'base64')) 47 | .then(async (imageBuffer) => { 48 | await fs.writeFile(`dist/tmp/${process.platform}_test.png`, imageBuffer); 49 | }); 50 | await app.close(); 51 | }); 52 | 53 | test('see if dev tools are open', async (t) => { 54 | t.false(await app.evaluate(({webContents}) => !!webContents.getFocusedWebContents()?.isDevToolsOpened())); 55 | }); 56 | -------------------------------------------------------------------------------- /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 | "window:hamburgerMenu": "alt+f", 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": "ctrl+shift+q", 15 | "tab:new": "ctrl+shift+t", 16 | "tab:next": [ 17 | "ctrl+shift+]", 18 | "ctrl+shift+right", 19 | "ctrl+alt+right", 20 | "ctrl+tab" 21 | ], 22 | "tab:prev": [ 23 | "ctrl+shift+[", 24 | "ctrl+shift+left", 25 | "ctrl+alt+left", 26 | "ctrl+shift+tab" 27 | ], 28 | "tab:jump:prefix": "ctrl", 29 | "pane:next": "ctrl+pageup", 30 | "pane:prev": "ctrl+pagedown", 31 | "pane:splitRight": "ctrl+shift+d", 32 | "pane:splitDown": "ctrl+shift+e", 33 | "pane:close": "ctrl+shift+w", 34 | "editor:undo": "ctrl+shift+z", 35 | "editor:redo": "ctrl+shift+y", 36 | "editor:cut": "ctrl+shift+x", 37 | "editor:copy": "ctrl+shift+c", 38 | "editor:paste": "ctrl+shift+v", 39 | "editor:selectAll": "ctrl+shift+a", 40 | "editor:search": "ctrl+shift+f", 41 | "editor:search-close": "esc", 42 | "editor:movePreviousWord": "ctrl+left", 43 | "editor:moveNextWord": "ctrl+right", 44 | "editor:moveBeginningLine": "home", 45 | "editor:moveEndLine": "end", 46 | "editor:deletePreviousWord": "ctrl+backspace", 47 | "editor:deleteNextWord": "ctrl+del", 48 | "editor:deleteBeginningLine": "ctrl+home", 49 | "editor:deleteEndLine": "ctrl+end", 50 | "editor:clearBuffer": "ctrl+shift+k", 51 | "editor:break": "ctrl+c", 52 | "plugins:update": "ctrl+shift+u" 53 | } 54 | -------------------------------------------------------------------------------- /app/menus/menus/view.ts: -------------------------------------------------------------------------------- 1 | import type {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 2 | 3 | const viewMenu = ( 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 | 59 | export default viewMenu; 60 | -------------------------------------------------------------------------------- /lib/utils/ipc-child-process.ts: -------------------------------------------------------------------------------- 1 | import type {ExecFileOptions, ExecOptions} from 'child_process'; 2 | 3 | import {ipcRenderer} from './ipc'; 4 | 5 | export function exec(command: string, options: ExecOptions, callback: (..._args: any) => void) { 6 | if (typeof options === 'function') { 7 | callback = options; 8 | options = {}; 9 | } 10 | ipcRenderer.invoke('child_process.exec', command, options).then( 11 | ({stdout, stderr}) => callback?.(null, stdout, stderr), 12 | (error) => callback?.(error, '', '') 13 | ); 14 | } 15 | 16 | export function execSync() { 17 | console.error('Calling execSync from renderer is disabled'); 18 | } 19 | 20 | export function execFile(file: string, args: string[], options: ExecFileOptions, callback: (..._args: any) => void) { 21 | if (typeof options === 'function') { 22 | callback = options; 23 | options = {}; 24 | } 25 | if (typeof args === 'function') { 26 | callback = args; 27 | args = []; 28 | options = {}; 29 | } 30 | ipcRenderer.invoke('child_process.execFile', file, args, options).then( 31 | ({stdout, stderr}) => callback?.(null, stdout, stderr), 32 | (error) => callback?.(error, '', '') 33 | ); 34 | } 35 | 36 | export function execFileSync() { 37 | console.error('Calling execFileSync from renderer is disabled'); 38 | } 39 | 40 | export function spawn() { 41 | console.error('Calling spawn from renderer is disabled'); 42 | } 43 | 44 | export function spawnSync() { 45 | console.error('Calling spawnSync from renderer is disabled'); 46 | } 47 | 48 | export function fork() { 49 | console.error('Calling fork from renderer is disabled'); 50 | } 51 | 52 | const IPCChildProcess = { 53 | exec, 54 | execSync, 55 | execFile, 56 | execFileSync, 57 | spawn, 58 | spawnSync, 59 | fork 60 | }; 61 | 62 | export default IPCChildProcess; 63 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/config/import.ts: -------------------------------------------------------------------------------- 1 | import {readFileSync, mkdirpSync} from 'fs-extra'; 2 | 3 | import type {rawConfig} from '../../typings/config'; 4 | import notify from '../notify'; 5 | 6 | import {_init} from './init'; 7 | import {migrateHyper3Config} from './migrate'; 8 | import {defaultCfg, cfgPath, plugs, defaultPlatformKeyPath} from './paths'; 9 | 10 | let defaultConfig: rawConfig; 11 | 12 | const _importConf = () => { 13 | // init plugin directories if not present 14 | mkdirpSync(plugs.base); 15 | mkdirpSync(plugs.local); 16 | 17 | try { 18 | migrateHyper3Config(); 19 | } catch (err) { 20 | console.error(err); 21 | } 22 | 23 | let defaultCfgRaw = '{}'; 24 | try { 25 | defaultCfgRaw = readFileSync(defaultCfg, 'utf8'); 26 | } catch (err) { 27 | console.log(err); 28 | } 29 | const _defaultCfg = JSON.parse(defaultCfgRaw) as rawConfig; 30 | 31 | // Importing platform specific keymap 32 | let content = '{}'; 33 | try { 34 | content = readFileSync(defaultPlatformKeyPath(), 'utf8'); 35 | } catch (err) { 36 | console.error(err); 37 | } 38 | const mapping = JSON.parse(content) as Record; 39 | _defaultCfg.keymaps = mapping; 40 | 41 | // Import user config 42 | let userCfg: rawConfig; 43 | try { 44 | userCfg = JSON.parse(readFileSync(cfgPath, 'utf8')); 45 | } catch (err) { 46 | notify("Couldn't parse config file. Using default config instead."); 47 | userCfg = JSON.parse(defaultCfgRaw); 48 | } 49 | 50 | return {userCfg, defaultCfg: _defaultCfg}; 51 | }; 52 | 53 | export const _import = () => { 54 | const imported = _importConf(); 55 | defaultConfig = imported.defaultCfg; 56 | const result = _init(imported.userCfg, imported.defaultCfg); 57 | return result; 58 | }; 59 | 60 | export const getDefaultConfig = () => { 61 | if (!defaultConfig) { 62 | defaultConfig = _importConf().defaultCfg; 63 | } 64 | return defaultConfig; 65 | }; 66 | -------------------------------------------------------------------------------- /app/utils/system-context-menu.ts: -------------------------------------------------------------------------------- 1 | import * as Registry from 'native-reg'; 2 | import type {HKEY} from 'native-reg'; 3 | 4 | const appPath = `"${process.execPath}"`; 5 | const regKeys = [ 6 | `Software\\Classes\\Directory\\Background\\shell\\Hyper`, 7 | `Software\\Classes\\Directory\\shell\\Hyper`, 8 | `Software\\Classes\\Drive\\shell\\Hyper` 9 | ]; 10 | const regParts = [ 11 | {key: 'command', name: '', value: `${appPath} "%V"`}, 12 | {name: '', value: 'Open &Hyper here'}, 13 | {name: 'Icon', value: `${appPath}`} 14 | ]; 15 | 16 | function addValues(hyperKey: HKEY, commandKey: HKEY) { 17 | try { 18 | Registry.setValueSZ(hyperKey, regParts[1].name, regParts[1].value); 19 | } catch (error) { 20 | console.error(error); 21 | } 22 | try { 23 | Registry.setValueSZ(hyperKey, regParts[2].name, regParts[2].value); 24 | } catch (err) { 25 | console.error(err); 26 | } 27 | try { 28 | Registry.setValueSZ(commandKey, regParts[0].name, regParts[0].value); 29 | } catch (err_) { 30 | console.error(err_); 31 | } 32 | } 33 | 34 | export const add = () => { 35 | regKeys.forEach((regKey) => { 36 | try { 37 | const hyperKey = 38 | Registry.openKey(Registry.HKCU, regKey, Registry.Access.ALL_ACCESS) || 39 | Registry.createKey(Registry.HKCU, regKey, Registry.Access.ALL_ACCESS); 40 | const commandKey = 41 | Registry.openKey(Registry.HKCU, `${regKey}\\${regParts[0].key}`, Registry.Access.ALL_ACCESS) || 42 | Registry.createKey(Registry.HKCU, `${regKey}\\${regParts[0].key}`, Registry.Access.ALL_ACCESS); 43 | addValues(hyperKey, commandKey); 44 | Registry.closeKey(hyperKey); 45 | Registry.closeKey(commandKey); 46 | } catch (error) { 47 | console.error(error); 48 | } 49 | }); 50 | }; 51 | 52 | export const remove = () => { 53 | regKeys.forEach((regKey) => { 54 | try { 55 | Registry.deleteTree(Registry.HKCU, regKey); 56 | } catch (err) { 57 | console.error(err); 58 | } 59 | }); 60 | }; 61 | -------------------------------------------------------------------------------- /bin/cp-snapshot.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const {Arch} = require('electron-builder'); 4 | 5 | function copySnapshot(pathToElectron, archToCopy) { 6 | const snapshotFileName = 'snapshot_blob.bin'; 7 | const v8ContextFileName = getV8ContextFileName(archToCopy); 8 | const pathToBlob = path.resolve(__dirname, '..', 'cache', archToCopy, snapshotFileName); 9 | const pathToBlobV8 = path.resolve(__dirname, '..', 'cache', archToCopy, v8ContextFileName); 10 | 11 | console.log('Copying v8 snapshots from', pathToBlob, 'to', pathToElectron); 12 | fs.copyFileSync(pathToBlob, path.join(pathToElectron, snapshotFileName)); 13 | fs.copyFileSync(pathToBlobV8, path.join(pathToElectron, v8ContextFileName)); 14 | } 15 | 16 | function getPathToElectron() { 17 | switch (process.platform) { 18 | case 'darwin': 19 | return path.resolve( 20 | __dirname, 21 | '..', 22 | 'node_modules/electron/dist/Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Resources' 23 | ); 24 | case 'win32': 25 | case 'linux': 26 | return path.resolve(__dirname, '..', 'node_modules', 'electron', 'dist'); 27 | } 28 | } 29 | 30 | function getV8ContextFileName(archToCopy) { 31 | if (process.platform === 'darwin') { 32 | return `v8_context_snapshot${archToCopy === 'arm64' ? '.arm64' : '.x86_64'}.bin`; 33 | } else { 34 | return `v8_context_snapshot.bin`; 35 | } 36 | } 37 | 38 | exports.default = async (context) => { 39 | const archToCopy = Arch[context.arch]; 40 | const pathToElectron = 41 | process.platform === 'darwin' 42 | ? `${context.appOutDir}/Hyper.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Resources` 43 | : context.appOutDir; 44 | copySnapshot(pathToElectron, archToCopy); 45 | }; 46 | 47 | if (require.main === module) { 48 | const archToCopy = process.env.npm_config_arch; 49 | const pathToElectron = getPathToElectron(); 50 | if ((process.arch.startsWith('arm') ? 'arm64' : 'x64') === archToCopy) { 51 | copySnapshot(pathToElectron, archToCopy); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /bin/mk-snapshot.js: -------------------------------------------------------------------------------- 1 | const childProcess = require('child_process'); 2 | const vm = require('vm'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const electronLink = require('electron-link'); 6 | const {mkdirp} = require('fs-extra'); 7 | 8 | const excludedModules = {}; 9 | 10 | const crossArchDirs = ['clang_x86_v8_arm', 'clang_x64_v8_arm64', 'win_clang_x64']; 11 | 12 | async function main() { 13 | const baseDirPath = path.resolve(__dirname, '..'); 14 | 15 | console.log('Creating a linked script..'); 16 | const result = await electronLink({ 17 | baseDirPath: baseDirPath, 18 | mainPath: `${__dirname}/snapshot-libs.js`, 19 | cachePath: `${baseDirPath}/cache`, 20 | // eslint-disable-next-line no-prototype-builtins 21 | shouldExcludeModule: (modulePath) => excludedModules.hasOwnProperty(modulePath) 22 | }); 23 | 24 | const snapshotScriptPath = `${baseDirPath}/cache/snapshot-libs.js`; 25 | fs.writeFileSync(snapshotScriptPath, result.snapshotScript); 26 | 27 | // Verify if we will be able to use this in `mksnapshot` 28 | vm.runInNewContext(result.snapshotScript, undefined, {filename: snapshotScriptPath, displayErrors: true}); 29 | 30 | const outputBlobPath = `${baseDirPath}/cache/${process.env.npm_config_arch}`; 31 | await mkdirp(outputBlobPath); 32 | 33 | if (process.platform !== 'darwin') { 34 | const mksnapshotBinPath = `${baseDirPath}/node_modules/electron-mksnapshot/bin`; 35 | const matchingDirs = crossArchDirs.map((dir) => `${mksnapshotBinPath}/${dir}`).filter((dir) => fs.existsSync(dir)); 36 | for (const dir of matchingDirs) { 37 | if (fs.existsSync(`${mksnapshotBinPath}/gen/v8/embedded.S`)) { 38 | await mkdirp(`${dir}/gen/v8`); 39 | fs.copyFileSync(`${mksnapshotBinPath}/gen/v8/embedded.S`, `${dir}/gen/v8/embedded.S`); 40 | } 41 | } 42 | } 43 | 44 | console.log(`Generating startup blob in "${outputBlobPath}"`); 45 | childProcess.execFileSync( 46 | path.resolve(__dirname, '..', 'node_modules', '.bin', 'mksnapshot' + (process.platform === 'win32' ? '.cmd' : '')), 47 | [snapshotScriptPath, '--output_dir', outputBlobPath] 48 | ); 49 | } 50 | 51 | main().catch((err) => console.error(err)); 52 | -------------------------------------------------------------------------------- /lib/actions/header.ts: -------------------------------------------------------------------------------- 1 | import {CLOSE_TAB, CHANGE_TAB} from '../../typings/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 '../../typings/constants/ui'; 9 | import type {HyperDispatch} from '../../typings/hyper'; 10 | import rpc from '../rpc'; 11 | 12 | import {userExitTermGroup, setActiveGroup} from './term-groups'; 13 | 14 | export function closeTab(uid: string) { 15 | return (dispatch: HyperDispatch) => { 16 | dispatch({ 17 | type: CLOSE_TAB, 18 | uid, 19 | effect() { 20 | dispatch(userExitTermGroup(uid)); 21 | } 22 | }); 23 | }; 24 | } 25 | 26 | export function changeTab(uid: string) { 27 | return (dispatch: HyperDispatch) => { 28 | dispatch({ 29 | type: CHANGE_TAB, 30 | uid, 31 | effect() { 32 | dispatch(setActiveGroup(uid)); 33 | } 34 | }); 35 | }; 36 | } 37 | 38 | export function maximize() { 39 | return (dispatch: HyperDispatch) => { 40 | dispatch({ 41 | type: UI_WINDOW_MAXIMIZE, 42 | effect() { 43 | rpc.emit('maximize'); 44 | } 45 | }); 46 | }; 47 | } 48 | 49 | export function unmaximize() { 50 | return (dispatch: HyperDispatch) => { 51 | dispatch({ 52 | type: UI_WINDOW_UNMAXIMIZE, 53 | effect() { 54 | rpc.emit('unmaximize'); 55 | } 56 | }); 57 | }; 58 | } 59 | 60 | export function openHamburgerMenu(coordinates: {x: number; y: number}) { 61 | return (dispatch: HyperDispatch) => { 62 | dispatch({ 63 | type: UI_OPEN_HAMBURGER_MENU, 64 | effect() { 65 | rpc.emit('open hamburger menu', coordinates); 66 | } 67 | }); 68 | }; 69 | } 70 | 71 | export function minimize() { 72 | return (dispatch: HyperDispatch) => { 73 | dispatch({ 74 | type: UI_WINDOW_MINIMIZE, 75 | effect() { 76 | rpc.emit('minimize'); 77 | } 78 | }); 79 | }; 80 | } 81 | 82 | export function close() { 83 | return (dispatch: HyperDispatch) => { 84 | dispatch({ 85 | type: UI_WINDOW_CLOSE, 86 | effect() { 87 | rpc.emit('close'); 88 | } 89 | }); 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /app/config/config-default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./schema.json", 3 | "config": { 4 | "updateChannel": "stable", 5 | "fontSize": 12, 6 | "fontFamily": "Menlo, \"DejaVu Sans Mono\", Consolas, \"Lucida Console\", monospace", 7 | "fontWeight": "normal", 8 | "fontWeightBold": "bold", 9 | "lineHeight": 1, 10 | "letterSpacing": 0, 11 | "scrollback": 1000, 12 | "cursorColor": "rgba(248,28,229,0.8)", 13 | "cursorAccentColor": "#000", 14 | "cursorShape": "BLOCK", 15 | "cursorBlink": false, 16 | "foregroundColor": "#fff", 17 | "backgroundColor": "#000", 18 | "selectionColor": "rgba(248,28,229,0.3)", 19 | "borderColor": "#333", 20 | "css": "", 21 | "termCSS": "", 22 | "workingDirectory": "", 23 | "showHamburgerMenu": "", 24 | "showWindowControls": "", 25 | "padding": "12px 14px", 26 | "colors": { 27 | "black": "#000000", 28 | "red": "#C51E14", 29 | "green": "#1DC121", 30 | "yellow": "#C7C329", 31 | "blue": "#0A2FC4", 32 | "magenta": "#C839C5", 33 | "cyan": "#20C5C6", 34 | "white": "#C7C7C7", 35 | "lightBlack": "#686868", 36 | "lightRed": "#FD6F6B", 37 | "lightGreen": "#67F86F", 38 | "lightYellow": "#FFFA72", 39 | "lightBlue": "#6A76FB", 40 | "lightMagenta": "#FD7CFC", 41 | "lightCyan": "#68FDFE", 42 | "lightWhite": "#FFFFFF", 43 | "limeGreen": "#32CD32", 44 | "lightCoral": "#F08080" 45 | }, 46 | "shell": "", 47 | "shellArgs": [ 48 | "--login" 49 | ], 50 | "env": {}, 51 | "bell": "SOUND", 52 | "bellSound": null, 53 | "bellSoundURL": null, 54 | "copyOnSelect": false, 55 | "defaultSSHApp": true, 56 | "quickEdit": false, 57 | "macOptionSelectionMode": "vertical", 58 | "webGLRenderer": false, 59 | "webLinksActivationKey": "", 60 | "disableLigatures": true, 61 | "disableAutoUpdates": false, 62 | "autoUpdatePlugins": true, 63 | "preserveCWD": true, 64 | "screenReaderMode": false, 65 | "imageSupport": true, 66 | "defaultProfile": "default", 67 | "profiles": [ 68 | { 69 | "name": "default", 70 | "config": {} 71 | } 72 | ] 73 | }, 74 | "plugins": [], 75 | "localPlugins": [], 76 | "keymaps": {} 77 | } 78 | -------------------------------------------------------------------------------- /app/config/init.ts: -------------------------------------------------------------------------------- 1 | import vm from 'vm'; 2 | 3 | import merge from 'lodash/merge'; 4 | 5 | import type {parsedConfig, rawConfig, configOptions} from '../../typings/config'; 6 | import notify from '../notify'; 7 | import mapKeys from '../utils/map-keys'; 8 | 9 | const _extract = (script?: vm.Script): Record => { 10 | const module: Record = {}; 11 | script?.runInNewContext({module}, {displayErrors: true}); 12 | if (!module.exports) { 13 | throw new Error('Error reading configuration: `module.exports` not set'); 14 | } 15 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 16 | return module.exports; 17 | }; 18 | 19 | const _syntaxValidation = (cfg: string) => { 20 | try { 21 | return new vm.Script(cfg, {filename: '.hyper.js'}); 22 | } catch (_err) { 23 | const err = _err as {name: string}; 24 | notify(`Error loading config: ${err.name}`, JSON.stringify(err), {error: err}); 25 | } 26 | }; 27 | 28 | const _extractDefault = (cfg: string) => { 29 | return _extract(_syntaxValidation(cfg)); 30 | }; 31 | 32 | // init config 33 | const _init = (userCfg: rawConfig, defaultCfg: rawConfig): parsedConfig => { 34 | return { 35 | config: (() => { 36 | if (userCfg?.config) { 37 | const conf = userCfg.config; 38 | conf.defaultProfile = conf.defaultProfile || 'default'; 39 | conf.profiles = conf.profiles || []; 40 | conf.profiles = conf.profiles.length > 0 ? conf.profiles : [{name: 'default', config: {}}]; 41 | conf.profiles = conf.profiles.map((p, i) => ({ 42 | ...p, 43 | name: p.name || `profile-${i + 1}`, 44 | config: p.config || {} 45 | })); 46 | if (!conf.profiles.map((p) => p.name).includes(conf.defaultProfile)) { 47 | conf.defaultProfile = conf.profiles[0].name; 48 | } 49 | return merge({}, defaultCfg.config, conf); 50 | } else { 51 | notify('Error reading configuration: `config` key is missing'); 52 | return defaultCfg.config || ({} as configOptions); 53 | } 54 | })(), 55 | // Merging platform specific keymaps with user defined keymaps 56 | keymaps: mapKeys({...defaultCfg.keymaps, ...userCfg?.keymaps}), 57 | // Ignore undefined values in plugin and localPlugins array Issue #1862 58 | plugins: userCfg?.plugins?.filter(Boolean) || [], 59 | localPlugins: userCfg?.localPlugins?.filter(Boolean) || [] 60 | }; 61 | }; 62 | 63 | export {_init, _extractDefault}; 64 | -------------------------------------------------------------------------------- /test/unit/window-utils.test.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line eslint-comments/disable-enable-pair 2 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 3 | import test from 'ava'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-var-requires 6 | const proxyquire = require('proxyquire').noCallThru(); 7 | 8 | test('positionIsValid() returns true when window is on only screen', (t) => { 9 | const position = [50, 50]; 10 | const windowUtils = proxyquire('../../app/utils/window-utils', { 11 | electron: { 12 | screen: { 13 | getAllDisplays: () => { 14 | return [ 15 | { 16 | workArea: { 17 | x: 0, 18 | y: 0, 19 | width: 500, 20 | height: 500 21 | } 22 | } 23 | ]; 24 | } 25 | } 26 | } 27 | }); 28 | 29 | const result = windowUtils.positionIsValid(position); 30 | 31 | t.true(result); 32 | }); 33 | 34 | test('positionIsValid() returns true when window is on second screen', (t) => { 35 | const position = [750, 50]; 36 | const windowUtils = proxyquire('../../app/utils/window-utils', { 37 | electron: { 38 | screen: { 39 | getAllDisplays: () => { 40 | return [ 41 | { 42 | workArea: { 43 | x: 0, 44 | y: 0, 45 | width: 500, 46 | height: 500 47 | } 48 | }, 49 | { 50 | workArea: { 51 | x: 500, 52 | y: 0, 53 | width: 500, 54 | height: 500 55 | } 56 | } 57 | ]; 58 | } 59 | } 60 | } 61 | }); 62 | 63 | const result = windowUtils.positionIsValid(position); 64 | 65 | t.true(result); 66 | }); 67 | 68 | test('positionIsValid() returns false when position isnt valid', (t) => { 69 | const primaryDisplay = { 70 | workArea: { 71 | x: 0, 72 | y: 0, 73 | width: 500, 74 | height: 500 75 | } 76 | }; 77 | const position = [600, 50]; 78 | const windowUtils = proxyquire('../../app/utils/window-utils', { 79 | electron: { 80 | screen: { 81 | getAllDisplays: () => { 82 | return [primaryDisplay]; 83 | }, 84 | getPrimaryDisplay: () => primaryDisplay 85 | } 86 | } 87 | }); 88 | 89 | const result = windowUtils.positionIsValid(position); 90 | 91 | t.false(result); 92 | }); 93 | -------------------------------------------------------------------------------- /.github/workflows/e2e_comment.yml: -------------------------------------------------------------------------------- 1 | name: Comment e2e test screenshots on PR 2 | on: 3 | workflow_run: 4 | workflows: ['Node CI'] 5 | types: 6 | - completed 7 | jobs: 8 | e2e_comment: 9 | runs-on: ubuntu-latest 10 | if: github.event.workflow_run.event == 'pull_request' 11 | steps: 12 | - name: Dump Workflow run info from GitHub context 13 | env: 14 | WORKFLOW_RUN_INFO: ${{ toJSON(github.event.workflow_run) }} 15 | run: echo "$WORKFLOW_RUN_INFO" 16 | - name: Download Artifacts 17 | uses: dawidd6/action-download-artifact@v3.1.4 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | workflow: nodejs.yml 21 | run_id: ${{ github.event.workflow_run.id }} 22 | name: e2e 23 | - name: Get PR number 24 | uses: dawidd6/action-download-artifact@v3.1.4 25 | with: 26 | github_token: ${{ secrets.GITHUB_TOKEN }} 27 | workflow: nodejs.yml 28 | run_id: ${{ github.event.workflow_run.id }} 29 | name: pr_num 30 | - name: Read the pr_num file 31 | id: pr_num_reader 32 | uses: juliangruber/read-file-action@v1.1.7 33 | with: 34 | path: ./pr_num.txt 35 | - name: List images 36 | run: ls -al 37 | - name: Upload images to imgur 38 | id: upload_screenshots 39 | uses: devicons/public-upload-to-imgur@v2.2.2 40 | with: 41 | path: ./*.png 42 | client_id: ${{ secrets.IMGUR_CLIENT_ID }} 43 | - name: Comment on the PR 44 | uses: jungwinter/comment@v1 45 | env: 46 | IMG_MARKDOWN: ${{ join(fromJSON(steps.upload_screenshots.outputs.markdown_urls), '') }} 47 | MESSAGE: | 48 | Hi there, 49 | Thank you for contributing to Hyper! 50 | You can get the build artifacts from [here](https://nightly.link/{1}/actions/runs/{2}). 51 | Here are screenshots of Hyper built from this pr. 52 | {0} 53 | with: 54 | type: create 55 | issue_number: ${{ steps.pr_num_reader.outputs.content }} 56 | token: ${{ secrets.GITHUB_TOKEN }} 57 | body: ${{ format(env.MESSAGE, env.IMG_MARKDOWN, github.repository, github.event.workflow_run.id) }} 58 | - name: Hide older comments 59 | uses: kanga333/comment-hider@v0.4.0 60 | with: 61 | github_token: ${{ secrets.GITHUB_TOKEN }} 62 | leave_visible: 1 63 | issue_number: ${{ steps.pr_num_reader.outputs.content }} 64 | -------------------------------------------------------------------------------- /app/rpc.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | 3 | import {ipcMain} from 'electron'; 4 | import type {BrowserWindow, IpcMainEvent} from 'electron'; 5 | 6 | import {v4 as uuidv4} from 'uuid'; 7 | 8 | import type {TypedEmitter, MainEvents, RendererEvents, FilterNever} from '../typings/common'; 9 | 10 | export class Server { 11 | emitter: TypedEmitter; 12 | destroyed = false; 13 | win: BrowserWindow; 14 | id!: string; 15 | 16 | constructor(win: BrowserWindow) { 17 | this.emitter = new EventEmitter(); 18 | this.win = win; 19 | this.emit = this.emit.bind(this); 20 | 21 | if (this.destroyed) { 22 | return; 23 | } 24 | 25 | const uid = uuidv4(); 26 | this.id = uid; 27 | 28 | ipcMain.on(uid, this.ipcListener); 29 | 30 | // we intentionally subscribe to `on` instead of `once` 31 | // to support reloading the window and re-initializing 32 | // the channel 33 | this.wc.on('did-finish-load', () => { 34 | this.wc.send('init', uid, win.profileName); 35 | }); 36 | } 37 | 38 | get wc() { 39 | return this.win.webContents; 40 | } 41 | 42 | ipcListener = (event: IpcMainEvent, {ev, data}: {ev: U; data: MainEvents[U]}) => 43 | this.emitter.emit(ev, data); 44 | 45 | on = (ev: U, fn: (arg0: MainEvents[U]) => void) => { 46 | this.emitter.on(ev, fn); 47 | return this; 48 | }; 49 | 50 | once = (ev: U, fn: (arg0: MainEvents[U]) => void) => { 51 | this.emitter.once(ev, fn); 52 | return this; 53 | }; 54 | 55 | emit>>(ch: U): boolean; 56 | emit>(ch: U, data: RendererEvents[U]): boolean; 57 | emit(ch: U, data?: RendererEvents[U]) { 58 | // This check is needed because data-batching can cause extra data to be 59 | // emitted after the window has already closed 60 | if (!this.win.isDestroyed()) { 61 | this.wc.send(this.id, {ch, data}); 62 | return true; 63 | } 64 | return false; 65 | } 66 | 67 | destroy() { 68 | this.emitter.removeAllListeners(); 69 | this.wc.removeAllListeners(); 70 | if (this.id) { 71 | ipcMain.removeListener(this.id, this.ipcListener); 72 | } else { 73 | // mark for `genUid` in constructor 74 | this.destroyed = true; 75 | } 76 | } 77 | } 78 | 79 | const createRPC = (win: BrowserWindow) => { 80 | return new Server(win); 81 | }; 82 | 83 | export default createRPC; 84 | -------------------------------------------------------------------------------- /lib/utils/rpc.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | 3 | import type {IpcRendererEvent} from 'electron'; 4 | 5 | import type { 6 | FilterNever, 7 | IpcRendererWithCommands, 8 | MainEvents, 9 | RendererEvents, 10 | TypedEmitter 11 | } from '../../typings/common'; 12 | 13 | import {ipcRenderer} from './ipc'; 14 | 15 | export default class Client { 16 | emitter: TypedEmitter; 17 | ipc: IpcRendererWithCommands; 18 | id!: string; 19 | 20 | constructor() { 21 | this.emitter = new EventEmitter(); 22 | this.ipc = ipcRenderer; 23 | this.emit = this.emit.bind(this); 24 | if (window.__rpcId) { 25 | setTimeout(() => { 26 | this.id = window.__rpcId; 27 | this.ipc.on(this.id, this.ipcListener); 28 | this.emitter.emit('ready'); 29 | }, 0); 30 | } else { 31 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 32 | this.ipc.on('init', (ev: IpcRendererEvent, uid: string, profileName: string) => { 33 | // we cache so that if the object 34 | // gets re-instantiated we don't 35 | // wait for a `init` event 36 | window.__rpcId = uid; 37 | // window.profileName = profileName; 38 | this.id = uid; 39 | this.ipc.on(uid, this.ipcListener); 40 | this.emitter.emit('ready'); 41 | }); 42 | } 43 | } 44 | 45 | ipcListener = ( 46 | event: IpcRendererEvent, 47 | {ch, data}: {ch: U; data: RendererEvents[U]} 48 | ) => this.emitter.emit(ch, data); 49 | 50 | on = (ev: U, fn: (arg0: RendererEvents[U]) => void) => { 51 | this.emitter.on(ev, fn); 52 | return this; 53 | }; 54 | 55 | once = (ev: U, fn: (arg0: RendererEvents[U]) => void) => { 56 | this.emitter.once(ev, fn); 57 | return this; 58 | }; 59 | 60 | emit>>(ev: U): boolean; 61 | emit>(ev: U, data: MainEvents[U]): boolean; 62 | emit(ev: U, data?: MainEvents[U]) { 63 | if (!this.id) { 64 | throw new Error('Not ready'); 65 | } 66 | this.ipc.send(this.id, {ev, data}); 67 | return true; 68 | } 69 | 70 | removeListener = (ev: U, fn: (arg0: RendererEvents[U]) => void) => { 71 | this.emitter.removeListener(ev, fn); 72 | return this; 73 | }; 74 | 75 | removeAllListeners = () => { 76 | this.emitter.removeAllListeners(); 77 | return this; 78 | }; 79 | 80 | destroy = () => { 81 | this.removeAllListeners(); 82 | this.ipc.removeAllListeners(this.id); 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ canary ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ canary ] 20 | schedule: 21 | - cron: '37 6 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v4 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v3 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v3 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v3 68 | -------------------------------------------------------------------------------- /app/config/open.ts: -------------------------------------------------------------------------------- 1 | import {exec} from 'child_process'; 2 | 3 | import {shell} from 'electron'; 4 | 5 | import * as Registry from 'native-reg'; 6 | 7 | import {cfgPath} from './paths'; 8 | 9 | const getUserChoiceKey = () => { 10 | try { 11 | // Load FileExts keys for .js files 12 | const fileExtsKeys = Registry.openKey( 13 | Registry.HKCU, 14 | 'Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\.js', 15 | Registry.Access.READ 16 | ); 17 | const keys = fileExtsKeys ? Registry.enumKeyNames(fileExtsKeys) : []; 18 | Registry.closeKey(fileExtsKeys); 19 | 20 | // Find UserChoice key 21 | const userChoice = keys.find((k) => k.endsWith('UserChoice')); 22 | return userChoice 23 | ? `Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\.js\\${userChoice}` 24 | : userChoice; 25 | } catch (error) { 26 | console.error(error); 27 | return; 28 | } 29 | }; 30 | 31 | const hasDefaultSet = () => { 32 | const userChoice = getUserChoiceKey(); 33 | if (!userChoice) return false; 34 | 35 | try { 36 | // Load key values 37 | const userChoiceKey = Registry.openKey(Registry.HKCU, userChoice, Registry.Access.READ)!; 38 | const values: string[] = Registry.enumValueNames(userChoiceKey).map( 39 | (x) => (Registry.queryValue(userChoiceKey, x) as string) || '' 40 | ); 41 | Registry.closeKey(userChoiceKey); 42 | 43 | // Look for default program 44 | const hasDefaultProgramConfigured = values.every( 45 | (value) => value && typeof value === 'string' && !value.includes('WScript.exe') && !value.includes('JSFile') 46 | ); 47 | 48 | return hasDefaultProgramConfigured; 49 | } catch (error) { 50 | console.error(error); 51 | return false; 52 | } 53 | }; 54 | 55 | // This mimics shell.openItem, true if it worked, false if not. 56 | const openNotepad = (file: string) => 57 | new Promise((resolve) => { 58 | exec(`start notepad.exe ${file}`, (error) => { 59 | resolve(!error); 60 | }); 61 | }); 62 | 63 | const openConfig = () => { 64 | // Windows opens .js files with WScript.exe by default 65 | // If the user hasn't set up an editor for .js files, we fallback to notepad. 66 | if (process.platform === 'win32') { 67 | try { 68 | if (hasDefaultSet()) { 69 | return shell.openPath(cfgPath).then((error) => error === ''); 70 | } 71 | console.warn('No default app set for .js files, using notepad.exe fallback'); 72 | } catch (err) { 73 | console.error('Open config with default app error:', err); 74 | } 75 | return openNotepad(cfgPath); 76 | } 77 | return shell.openPath(cfgPath).then((error) => error === ''); 78 | }; 79 | 80 | export default openConfig; 81 | -------------------------------------------------------------------------------- /app/config/paths.ts: -------------------------------------------------------------------------------- 1 | // This module exports paths, names, and other metadata that is referenced 2 | import {statSync} from 'fs'; 3 | import {homedir} from 'os'; 4 | import {resolve, join} from 'path'; 5 | 6 | import {app} from 'electron'; 7 | 8 | import isDev from 'electron-is-dev'; 9 | 10 | const cfgFile = 'hyper.json'; 11 | const defaultCfgFile = 'config-default.json'; 12 | const schemaFile = 'schema.json'; 13 | const homeDirectory = homedir(); 14 | 15 | // If the user defines XDG_CONFIG_HOME they definitely want their config there, 16 | // otherwise use the home directory in linux/mac and userdata in windows 17 | let cfgDir = process.env.XDG_CONFIG_HOME 18 | ? join(process.env.XDG_CONFIG_HOME, 'Hyper') 19 | : process.platform === 'win32' 20 | ? app.getPath('userData') 21 | : join(homeDirectory, '.config', 'Hyper'); 22 | 23 | const legacyCfgPath = join( 24 | process.env.XDG_CONFIG_HOME !== undefined 25 | ? join(process.env.XDG_CONFIG_HOME, 'hyper') 26 | : process.platform == 'win32' 27 | ? app.getPath('userData') 28 | : homedir(), 29 | '.hyper.js' 30 | ); 31 | 32 | let cfgPath = join(cfgDir, cfgFile); 33 | const schemaPath = resolve(__dirname, schemaFile); 34 | 35 | const devDir = resolve(__dirname, '../..'); 36 | const devCfg = join(devDir, cfgFile); 37 | const defaultCfg = resolve(__dirname, defaultCfgFile); 38 | 39 | if (isDev) { 40 | // if a local config file exists, use it 41 | try { 42 | statSync(devCfg); 43 | cfgPath = devCfg; 44 | cfgDir = devDir; 45 | console.log('using config file:', cfgPath); 46 | } catch (err) { 47 | // ignore 48 | } 49 | } 50 | 51 | const plugins = resolve(cfgDir, 'plugins'); 52 | const plugs = { 53 | base: plugins, 54 | local: resolve(plugins, 'local'), 55 | cache: resolve(plugins, 'cache') 56 | }; 57 | const yarn = resolve(__dirname, '../../bin/yarn-standalone.js'); 58 | const cliScriptPath = resolve(__dirname, '../../bin/hyper'); 59 | const cliLinkPath = '/usr/local/bin/hyper'; 60 | 61 | const icon = resolve(__dirname, '../static/icon96x96.png'); 62 | 63 | const keymapPath = resolve(__dirname, '../keymaps'); 64 | const darwinKeys = join(keymapPath, 'darwin.json'); 65 | const win32Keys = join(keymapPath, 'win32.json'); 66 | const linuxKeys = join(keymapPath, 'linux.json'); 67 | 68 | const defaultPlatformKeyPath = () => { 69 | switch (process.platform) { 70 | case 'darwin': 71 | return darwinKeys; 72 | case 'win32': 73 | return win32Keys; 74 | case 'linux': 75 | return linuxKeys; 76 | default: 77 | return darwinKeys; 78 | } 79 | }; 80 | 81 | export { 82 | cfgDir, 83 | cfgPath, 84 | legacyCfgPath, 85 | cfgFile, 86 | defaultCfg, 87 | icon, 88 | defaultPlatformKeyPath, 89 | plugs, 90 | yarn, 91 | cliScriptPath, 92 | cliLinkPath, 93 | homeDirectory, 94 | schemaFile, 95 | schemaPath 96 | }; 97 | -------------------------------------------------------------------------------- /app/menus/menus/window.ts: -------------------------------------------------------------------------------- 1 | import type {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 2 | 3 | const windowMenu = ( 4 | commandKeys: Record, 5 | execCommand: (command: string, focusedWindow?: BrowserWindow) => void 6 | ): MenuItemConstructorOptions => { 7 | // Generating tab:jump array 8 | const tabJump: MenuItemConstructorOptions[] = []; 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 | label: 'Toggle Always on Top', 86 | click: (item, focusedWindow) => { 87 | execCommand('window:toggleKeepOnTop', focusedWindow); 88 | } 89 | }, 90 | { 91 | role: 'togglefullscreen', 92 | accelerator: commandKeys['window:toggleFullScreen'] 93 | } 94 | ] 95 | }; 96 | }; 97 | 98 | export default windowMenu; 99 | -------------------------------------------------------------------------------- /typings/constants/sessions.d.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_SET_XTERM_TITLE = 'SESSION_SET_XTERM_TITLE'; 12 | export const SESSION_SET_CWD = 'SESSION_SET_CWD'; 13 | export const SESSION_SEARCH = 'SESSION_SEARCH'; 14 | 15 | export interface SessionAddAction { 16 | type: typeof SESSION_ADD; 17 | uid: string; 18 | shell: string | null; 19 | pid: number | null; 20 | cols: number | null; 21 | rows: number | null; 22 | splitDirection?: 'HORIZONTAL' | 'VERTICAL'; 23 | activeUid: string | null; 24 | now: number; 25 | profile: string; 26 | } 27 | export interface SessionResizeAction { 28 | type: typeof SESSION_RESIZE; 29 | uid: string; 30 | cols: number; 31 | rows: number; 32 | isStandaloneTerm: boolean; 33 | now: number; 34 | } 35 | export interface SessionRequestAction { 36 | type: typeof SESSION_REQUEST; 37 | } 38 | export interface SessionAddDataAction { 39 | type: typeof SESSION_ADD_DATA; 40 | } 41 | export interface SessionPtyDataAction { 42 | type: typeof SESSION_PTY_DATA; 43 | data: string; 44 | uid: string; 45 | now: number; 46 | } 47 | export interface SessionPtyExitAction { 48 | type: typeof SESSION_PTY_EXIT; 49 | uid: string; 50 | } 51 | export interface SessionUserExitAction { 52 | type: typeof SESSION_USER_EXIT; 53 | uid: string; 54 | } 55 | export interface SessionSetActiveAction { 56 | type: typeof SESSION_SET_ACTIVE; 57 | uid: string; 58 | } 59 | export interface SessionClearActiveAction { 60 | type: typeof SESSION_CLEAR_ACTIVE; 61 | } 62 | export interface SessionUserDataAction { 63 | type: typeof SESSION_USER_DATA; 64 | } 65 | export interface SessionSetXtermTitleAction { 66 | type: typeof SESSION_SET_XTERM_TITLE; 67 | uid: string; 68 | title: string; 69 | } 70 | export interface SessionSetCwdAction { 71 | type: typeof SESSION_SET_CWD; 72 | cwd: string; 73 | } 74 | export interface SessionSearchAction { 75 | type: typeof SESSION_SEARCH; 76 | uid: string; 77 | value: boolean; 78 | } 79 | 80 | export type SessionActions = 81 | | SessionAddAction 82 | | SessionResizeAction 83 | | SessionRequestAction 84 | | SessionAddDataAction 85 | | SessionPtyDataAction 86 | | SessionPtyExitAction 87 | | SessionUserExitAction 88 | | SessionSetActiveAction 89 | | SessionClearActiveAction 90 | | SessionUserDataAction 91 | | SessionSetXtermTitleAction 92 | | SessionSetCwdAction 93 | | SessionSearchAction; 94 | -------------------------------------------------------------------------------- /lib/containers/notifications.ts: -------------------------------------------------------------------------------- 1 | import type {HyperState, HyperDispatch} from '../../typings/hyper'; 2 | import {dismissNotification} from '../actions/notifications'; 3 | import {installUpdate} from '../actions/updater'; 4 | import Notifications from '../components/notifications'; 5 | import {connect} from '../utils/plugins'; 6 | 7 | const mapStateToProps = (state: HyperState) => { 8 | const {ui} = state; 9 | const {notifications} = ui; 10 | let state_: Partial<{ 11 | fontShowing: boolean; 12 | fontSize: number; 13 | fontText: string; 14 | resizeShowing: boolean; 15 | cols: number | null; 16 | rows: number | null; 17 | updateShowing: boolean; 18 | updateVersion: string | null; 19 | updateNote: string | null; 20 | updateReleaseUrl: string | null; 21 | updateCanInstall: boolean | null; 22 | messageShowing: boolean; 23 | messageText: string | null; 24 | messageURL: string | null; 25 | messageDismissable: boolean | null; 26 | }> = {}; 27 | 28 | if (notifications.font) { 29 | const fontSize = ui.fontSizeOverride || ui.fontSize; 30 | 31 | state_ = { 32 | ...state_, 33 | fontShowing: true, 34 | fontSize, 35 | fontText: `${fontSize}px` 36 | }; 37 | } 38 | 39 | if (notifications.resize) { 40 | const cols = ui.cols; 41 | const rows = ui.rows; 42 | 43 | state_ = { 44 | ...state_, 45 | resizeShowing: true, 46 | cols, 47 | rows 48 | }; 49 | } 50 | 51 | if (notifications.updates) { 52 | state_ = { 53 | ...state_, 54 | updateShowing: true, 55 | updateVersion: ui.updateVersion, 56 | updateNote: ui.updateNotes!.split('\n')[0], 57 | updateReleaseUrl: ui.updateReleaseUrl, 58 | updateCanInstall: ui.updateCanInstall 59 | }; 60 | } else if (notifications.message) { 61 | state_ = { 62 | ...state_, 63 | messageShowing: true, 64 | messageText: ui.messageText, 65 | messageURL: ui.messageURL, 66 | messageDismissable: ui.messageDismissable 67 | }; 68 | } 69 | 70 | return state_; 71 | }; 72 | 73 | const mapDispatchToProps = (dispatch: HyperDispatch) => { 74 | return { 75 | onDismissFont: () => { 76 | dispatch(dismissNotification('font')); 77 | }, 78 | onDismissResize: () => { 79 | dispatch(dismissNotification('resize')); 80 | }, 81 | onDismissUpdate: () => { 82 | dispatch(dismissNotification('updates')); 83 | }, 84 | onDismissMessage: () => { 85 | dispatch(dismissNotification('message')); 86 | }, 87 | onUpdateInstall: () => { 88 | dispatch(installUpdate()); 89 | } 90 | }; 91 | }; 92 | 93 | const NotificationsContainer = connect(mapStateToProps, mapDispatchToProps, null)(Notifications, 'Notifications'); 94 | 95 | export default NotificationsContainer; 96 | 97 | export type NotificationsConnectedProps = ReturnType & ReturnType; 98 | -------------------------------------------------------------------------------- /lib/containers/header.ts: -------------------------------------------------------------------------------- 1 | import {createSelector} from 'reselect'; 2 | 3 | import type {HyperState, HyperDispatch, ITab} from '../../typings/hyper'; 4 | import {closeTab, changeTab, maximize, openHamburgerMenu, unmaximize, minimize, close} from '../actions/header'; 5 | import {requestTermGroup} from '../actions/term-groups'; 6 | import Header from '../components/header'; 7 | import {getRootGroups} from '../selectors'; 8 | import {connect} from '../utils/plugins'; 9 | 10 | const isMac = /Mac/.test(navigator.userAgent); 11 | 12 | const getSessions = ({sessions}: HyperState) => sessions.sessions; 13 | const getActiveRootGroup = ({termGroups}: HyperState) => termGroups.activeRootGroup; 14 | const getActiveSessions = ({termGroups}: HyperState) => termGroups.activeSessions; 15 | const getActivityMarkers = ({ui}: HyperState) => ui.activityMarkers; 16 | const getTabs = createSelector( 17 | [getSessions, getRootGroups, getActiveSessions, getActiveRootGroup, getActivityMarkers], 18 | (sessions, rootGroups, activeSessions, activeRootGroup, activityMarkers) => 19 | rootGroups.map((t): ITab => { 20 | const activeSessionUid = activeSessions[t.uid]; 21 | const session = sessions[activeSessionUid]; 22 | return { 23 | uid: t.uid, 24 | title: session.title, 25 | isActive: t.uid === activeRootGroup, 26 | hasActivity: activityMarkers[session.uid] 27 | }; 28 | }) 29 | ); 30 | 31 | const mapStateToProps = (state: HyperState) => { 32 | return { 33 | // active is an index 34 | isMac, 35 | tabs: getTabs(state), 36 | activeMarkers: state.ui.activityMarkers, 37 | borderColor: state.ui.borderColor, 38 | backgroundColor: state.ui.backgroundColor, 39 | maximized: state.ui.maximized, 40 | fullScreen: state.ui.fullScreen, 41 | showHamburgerMenu: state.ui.showHamburgerMenu, 42 | showWindowControls: state.ui.showWindowControls, 43 | defaultProfile: state.ui.defaultProfile, 44 | profiles: state.ui.profiles 45 | }; 46 | }; 47 | 48 | const mapDispatchToProps = (dispatch: HyperDispatch) => { 49 | return { 50 | onCloseTab: (i: string) => { 51 | dispatch(closeTab(i)); 52 | }, 53 | 54 | onChangeTab: (i: string) => { 55 | dispatch(changeTab(i)); 56 | }, 57 | 58 | maximize: () => { 59 | dispatch(maximize()); 60 | }, 61 | 62 | unmaximize: () => { 63 | dispatch(unmaximize()); 64 | }, 65 | 66 | openHamburgerMenu: (coordinates: {x: number; y: number}) => { 67 | dispatch(openHamburgerMenu(coordinates)); 68 | }, 69 | 70 | minimize: () => { 71 | dispatch(minimize()); 72 | }, 73 | 74 | close: () => { 75 | dispatch(close()); 76 | }, 77 | 78 | openNewTab: (profile: string) => { 79 | dispatch(requestTermGroup(undefined, profile)); 80 | } 81 | }; 82 | }; 83 | 84 | export const HeaderContainer = connect(mapStateToProps, mapDispatchToProps, null)(Header, 'Header'); 85 | 86 | export type HeaderConnectedProps = ReturnType & ReturnType; 87 | -------------------------------------------------------------------------------- /app/menus/menu.ts: -------------------------------------------------------------------------------- 1 | // Packages 2 | import {app, dialog, Menu} from 'electron'; 3 | import type {BrowserWindow} from 'electron'; 4 | 5 | // Utilities 6 | import {execCommand} from '../commands'; 7 | import {getConfig} from '../config'; 8 | import {icon} from '../config/paths'; 9 | import {getDecoratedKeymaps} from '../plugins'; 10 | import {getRendererTypes} from '../utils/renderer-utils'; 11 | 12 | import darwinMenu from './menus/darwin'; 13 | import editMenu from './menus/edit'; 14 | import helpMenu from './menus/help'; 15 | import shellMenu from './menus/shell'; 16 | import toolsMenu from './menus/tools'; 17 | import viewMenu from './menus/view'; 18 | import windowMenu from './menus/window'; 19 | 20 | const appName = app.name; 21 | const appVersion = app.getVersion(); 22 | 23 | let menu_: Menu; 24 | 25 | export const createMenu = ( 26 | createWindow: (fn?: (win: BrowserWindow) => void, options?: Record) => BrowserWindow, 27 | getLoadedPluginVersions: () => {name: string; version: string}[] 28 | ) => { 29 | const config = getConfig(); 30 | // We take only first shortcut in array for each command 31 | const allCommandKeys = getDecoratedKeymaps(); 32 | const commandKeys = Object.keys(allCommandKeys).reduce((result: Record, command) => { 33 | result[command] = allCommandKeys[command][0]; 34 | return result; 35 | }, {}); 36 | 37 | let updateChannel = 'stable'; 38 | 39 | if (config?.updateChannel && config.updateChannel === 'canary') { 40 | updateChannel = 'canary'; 41 | } 42 | 43 | const showAbout = () => { 44 | const loadedPlugins = getLoadedPluginVersions(); 45 | const pluginList = 46 | loadedPlugins.length === 0 ? 'none' : loadedPlugins.map((plugin) => `\n ${plugin.name} (${plugin.version})`); 47 | 48 | const rendererCounts = Object.values(getRendererTypes()).reduce((acc: Record, type) => { 49 | acc[type] = acc[type] ? acc[type] + 1 : 1; 50 | return acc; 51 | }, {}); 52 | const renderers = Object.entries(rendererCounts) 53 | .map(([type, count]) => type + (count > 1 ? ` (${count})` : '')) 54 | .join(', '); 55 | 56 | void dialog.showMessageBox({ 57 | title: `About ${appName}`, 58 | message: `${appName} ${appVersion} (${updateChannel})`, 59 | detail: `Renderers: ${renderers}\nPlugins: ${pluginList}\n\nCreated by Guillermo Rauch\nCopyright © 2022 Vercel, Inc.`, 60 | buttons: [], 61 | icon: icon as any 62 | }); 63 | }; 64 | const menu = [ 65 | ...(process.platform === 'darwin' ? [darwinMenu(commandKeys, execCommand, showAbout)] : []), 66 | shellMenu( 67 | commandKeys, 68 | execCommand, 69 | getConfig().profiles.map((p) => p.name) 70 | ), 71 | editMenu(commandKeys, execCommand), 72 | viewMenu(commandKeys, execCommand), 73 | toolsMenu(commandKeys, execCommand), 74 | windowMenu(commandKeys, execCommand), 75 | helpMenu(commandKeys, showAbout) 76 | ]; 77 | 78 | return menu; 79 | }; 80 | 81 | export const buildMenu = (template: Electron.MenuItemConstructorOptions[]): Electron.Menu => { 82 | menu_ = Menu.buildFromTemplate(template); 83 | return menu_; 84 | }; 85 | -------------------------------------------------------------------------------- /app/menus/menus/shell.ts: -------------------------------------------------------------------------------- 1 | import type {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 2 | 3 | const shellMenu = ( 4 | commandKeys: Record, 5 | execCommand: (command: string, focusedWindow?: BrowserWindow) => void, 6 | profiles: string[] 7 | ): MenuItemConstructorOptions => { 8 | const isMac = process.platform === 'darwin'; 9 | 10 | return { 11 | label: isMac ? 'Shell' : 'File', 12 | submenu: [ 13 | { 14 | label: 'New Tab', 15 | accelerator: commandKeys['tab:new'], 16 | click(item, focusedWindow) { 17 | execCommand('tab:new', focusedWindow); 18 | } 19 | }, 20 | { 21 | label: 'New Window', 22 | accelerator: commandKeys['window:new'], 23 | click(item, focusedWindow) { 24 | execCommand('window:new', focusedWindow); 25 | } 26 | }, 27 | { 28 | type: 'separator' 29 | }, 30 | { 31 | label: 'Split Down', 32 | accelerator: commandKeys['pane:splitDown'], 33 | click(item, focusedWindow) { 34 | execCommand('pane:splitDown', focusedWindow); 35 | } 36 | }, 37 | { 38 | label: 'Split Right', 39 | accelerator: commandKeys['pane:splitRight'], 40 | click(item, focusedWindow) { 41 | execCommand('pane:splitRight', focusedWindow); 42 | } 43 | }, 44 | { 45 | type: 'separator' 46 | }, 47 | ...profiles.map( 48 | (profile): MenuItemConstructorOptions => ({ 49 | label: profile, 50 | submenu: [ 51 | { 52 | label: 'New Tab', 53 | accelerator: commandKeys[`tab:new:${profile}`], 54 | click(item, focusedWindow) { 55 | execCommand(`tab:new:${profile}`, focusedWindow); 56 | } 57 | }, 58 | { 59 | label: 'New Window', 60 | accelerator: commandKeys[`window:new:${profile}`], 61 | click(item, focusedWindow) { 62 | execCommand(`window:new:${profile}`, focusedWindow); 63 | } 64 | }, 65 | { 66 | type: 'separator' 67 | }, 68 | { 69 | label: 'Split Down', 70 | accelerator: commandKeys[`pane:splitDown:${profile}`], 71 | click(item, focusedWindow) { 72 | execCommand(`pane:splitDown:${profile}`, focusedWindow); 73 | } 74 | }, 75 | { 76 | label: 'Split Right', 77 | accelerator: commandKeys[`pane:splitRight:${profile}`], 78 | click(item, focusedWindow) { 79 | execCommand(`pane:splitRight:${profile}`, focusedWindow); 80 | } 81 | } 82 | ] 83 | }) 84 | ), 85 | { 86 | type: 'separator' 87 | }, 88 | { 89 | label: 'Close', 90 | accelerator: commandKeys['pane:close'], 91 | click(item, focusedWindow) { 92 | execCommand('pane:close', focusedWindow); 93 | } 94 | }, 95 | { 96 | label: isMac ? 'Close Window' : 'Quit', 97 | role: 'close', 98 | accelerator: commandKeys['window:close'] 99 | } 100 | ] 101 | }; 102 | }; 103 | 104 | export default shellMenu; 105 | -------------------------------------------------------------------------------- /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/containers/terms.ts: -------------------------------------------------------------------------------- 1 | import type {HyperState, HyperDispatch} from '../../typings/hyper'; 2 | import { 3 | resizeSession, 4 | sendSessionData, 5 | setSessionXtermTitle, 6 | setActiveSession, 7 | openSearch, 8 | closeSearch 9 | } from '../actions/sessions'; 10 | import {openContextMenu} from '../actions/ui'; 11 | import Terms from '../components/terms'; 12 | import {getRootGroups} from '../selectors'; 13 | import {connect} from '../utils/plugins'; 14 | 15 | const mapStateToProps = (state: HyperState) => { 16 | const {sessions} = state.sessions; 17 | return { 18 | sessions, 19 | cols: state.ui.cols, 20 | rows: state.ui.rows, 21 | scrollback: state.ui.scrollback, 22 | termGroups: getRootGroups(state), 23 | activeRootGroup: state.termGroups.activeRootGroup, 24 | activeSession: state.sessions.activeUid, 25 | customCSS: state.ui.termCSS, 26 | write: state.sessions.write, 27 | fontSize: state.ui.fontSizeOverride ? state.ui.fontSizeOverride : state.ui.fontSize, 28 | fontFamily: state.ui.fontFamily, 29 | fontWeight: state.ui.fontWeight, 30 | fontWeightBold: state.ui.fontWeightBold, 31 | lineHeight: state.ui.lineHeight, 32 | letterSpacing: state.ui.letterSpacing, 33 | uiFontFamily: state.ui.uiFontFamily, 34 | fontSmoothing: state.ui.fontSmoothingOverride, 35 | padding: state.ui.padding, 36 | cursorColor: state.ui.cursorColor, 37 | cursorAccentColor: state.ui.cursorAccentColor, 38 | cursorShape: state.ui.cursorShape, 39 | cursorBlink: state.ui.cursorBlink, 40 | borderColor: state.ui.borderColor, 41 | selectionColor: state.ui.selectionColor, 42 | colors: state.ui.colors, 43 | foregroundColor: state.ui.foregroundColor, 44 | backgroundColor: state.ui.backgroundColor, 45 | bell: state.ui.bell, 46 | bellSoundURL: state.ui.bellSoundURL, 47 | bellSound: state.ui.bellSound, 48 | copyOnSelect: state.ui.copyOnSelect, 49 | modifierKeys: state.ui.modifierKeys, 50 | quickEdit: state.ui.quickEdit, 51 | webGLRenderer: state.ui.webGLRenderer, 52 | webLinksActivationKey: state.ui.webLinksActivationKey, 53 | macOptionSelectionMode: state.ui.macOptionSelectionMode, 54 | disableLigatures: state.ui.disableLigatures, 55 | screenReaderMode: state.ui.screenReaderMode, 56 | windowsPty: state.ui.windowsPty, 57 | imageSupport: state.ui.imageSupport 58 | }; 59 | }; 60 | 61 | const mapDispatchToProps = (dispatch: HyperDispatch) => { 62 | return { 63 | onData(uid: string, data: string) { 64 | dispatch(sendSessionData(uid, data)); 65 | }, 66 | 67 | onTitle(uid: string, title: string) { 68 | dispatch(setSessionXtermTitle(uid, title)); 69 | }, 70 | 71 | onResize(uid: string, cols: number, rows: number) { 72 | dispatch(resizeSession(uid, cols, rows)); 73 | }, 74 | 75 | onActive(uid: string) { 76 | dispatch(setActiveSession(uid)); 77 | }, 78 | 79 | onOpenSearch(uid: string) { 80 | dispatch(openSearch(uid)); 81 | }, 82 | 83 | onCloseSearch(uid: string) { 84 | dispatch(closeSearch(uid)); 85 | }, 86 | 87 | onContextMenu(uid: string, selection: string) { 88 | dispatch(setActiveSession(uid)); 89 | dispatch(openContextMenu(uid, selection)); 90 | } 91 | }; 92 | }; 93 | 94 | const TermsContainer = connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(Terms, 'Terms'); 95 | 96 | export default TermsContainer; 97 | 98 | export type TermsConnectedProps = ReturnType & ReturnType; 99 | -------------------------------------------------------------------------------- /lib/components/notification.tsx: -------------------------------------------------------------------------------- 1 | import React, {forwardRef, useEffect, useRef, useState} from 'react'; 2 | 3 | import type {NotificationProps} from '../../typings/hyper'; 4 | 5 | const Notification = forwardRef>((props, ref) => { 6 | const dismissTimer = useRef(undefined); 7 | const [dismissing, setDismissing] = useState(false); 8 | 9 | useEffect(() => { 10 | setDismissTimer(); 11 | }, []); 12 | 13 | useEffect(() => { 14 | // if we have a timer going and the notification text 15 | // changed we reset the timer 16 | resetDismissTimer(); 17 | setDismissing(false); 18 | }, [props.text]); 19 | 20 | const handleDismiss = () => { 21 | setDismissing(true); 22 | }; 23 | 24 | const onElement = (el: HTMLDivElement | null) => { 25 | if (el) { 26 | el.addEventListener('webkitTransitionEnd', () => { 27 | if (dismissing) { 28 | props.onDismiss(); 29 | } 30 | }); 31 | const {backgroundColor} = props; 32 | if (backgroundColor) { 33 | el.style.setProperty('background-color', backgroundColor, 'important'); 34 | } 35 | 36 | if (ref) { 37 | if (typeof ref === 'function') ref(el); 38 | else ref.current = el; 39 | } 40 | } 41 | }; 42 | 43 | const setDismissTimer = () => { 44 | if (typeof props.dismissAfter === 'number') { 45 | dismissTimer.current = setTimeout(() => { 46 | handleDismiss(); 47 | }, props.dismissAfter); 48 | } 49 | }; 50 | 51 | const resetDismissTimer = () => { 52 | clearTimeout(dismissTimer.current); 53 | setDismissTimer(); 54 | }; 55 | 56 | useEffect(() => { 57 | return () => { 58 | clearTimeout(dismissTimer.current); 59 | }; 60 | }, []); 61 | 62 | const {backgroundColor, color} = props; 63 | const opacity = dismissing ? 0 : 1; 64 | return ( 65 |
66 | {props.customChildrenBefore} 67 | {props.children || props.text} 68 | {props.userDismissable ? ( 69 | 70 | [x] 71 | 72 | ) : null} 73 | {props.customChildren} 74 | 75 | 104 |
105 | ); 106 | }); 107 | 108 | Notification.displayName = 'Notification'; 109 | 110 | export default Notification; 111 | -------------------------------------------------------------------------------- /lib/components/tabs.tsx: -------------------------------------------------------------------------------- 1 | import React, {forwardRef} from 'react'; 2 | 3 | import type {TabsProps} from '../../typings/hyper'; 4 | import {decorate, getTabProps} from '../utils/plugins'; 5 | 6 | import DropdownButton from './new-tab'; 7 | import Tab_ from './tab'; 8 | 9 | const Tab = decorate(Tab_, 'Tab'); 10 | const isMac = /Mac/.test(navigator.userAgent); 11 | 12 | const Tabs = forwardRef((props, ref) => { 13 | const {tabs = [], borderColor, onChange, onClose, fullScreen} = props; 14 | 15 | const hide = !isMac && tabs.length === 1; 16 | 17 | return ( 18 | 108 | ); 109 | }); 110 | 111 | Tabs.displayName = 'Tabs'; 112 | 113 | export default Tabs; 114 | -------------------------------------------------------------------------------- /app/updater.ts: -------------------------------------------------------------------------------- 1 | // Packages 2 | import electron, {app} from 'electron'; 3 | import type {BrowserWindow, AutoUpdater} from 'electron'; 4 | 5 | import retry from 'async-retry'; 6 | import ms from 'ms'; 7 | 8 | // Utilities 9 | import autoUpdaterLinux from './auto-updater-linux'; 10 | import {getDefaultProfile} from './config'; 11 | import {version} from './package.json'; 12 | import {getDecoratedConfig} from './plugins'; 13 | 14 | const {platform} = process; 15 | const isLinux = platform === 'linux'; 16 | 17 | const autoUpdater: AutoUpdater = isLinux ? autoUpdaterLinux : electron.autoUpdater; 18 | 19 | const getDecoratedConfigWithRetry = async () => { 20 | return await retry(() => { 21 | const content = getDecoratedConfig(getDefaultProfile()); 22 | if (!content) { 23 | throw new Error('No config content loaded'); 24 | } 25 | return content; 26 | }); 27 | }; 28 | 29 | const checkForUpdates = async () => { 30 | const config = await getDecoratedConfigWithRetry(); 31 | if (!config.disableAutoUpdates) { 32 | autoUpdater.checkForUpdates(); 33 | } 34 | }; 35 | 36 | let isInit = false; 37 | // Default to the "stable" update channel 38 | let canaryUpdates = false; 39 | 40 | const buildFeedUrl = (canary: boolean, currentVersion: string) => { 41 | const updatePrefix = canary ? 'releases-canary' : 'releases'; 42 | const archSuffix = process.arch === 'arm64' || app.runningUnderARM64Translation ? '_arm64' : ''; 43 | return `https://${updatePrefix}.hyper.is/update/${isLinux ? 'deb' : platform}${archSuffix}/${currentVersion}`; 44 | }; 45 | 46 | const isCanary = (updateChannel: string) => updateChannel === 'canary'; 47 | 48 | async function init() { 49 | autoUpdater.on('error', (err) => { 50 | console.error('Error fetching updates', `${err.message} (${err.stack})`); 51 | }); 52 | 53 | const config = await getDecoratedConfigWithRetry(); 54 | 55 | // If defined in the config, switch to the "canary" channel 56 | if (config.updateChannel && isCanary(config.updateChannel)) { 57 | canaryUpdates = true; 58 | } 59 | 60 | const feedURL = buildFeedUrl(canaryUpdates, version); 61 | 62 | autoUpdater.setFeedURL({url: feedURL}); 63 | 64 | setTimeout(() => { 65 | void checkForUpdates(); 66 | }, ms('10s')); 67 | 68 | setInterval(() => { 69 | void checkForUpdates(); 70 | }, ms('30m')); 71 | 72 | isInit = true; 73 | } 74 | 75 | const updater = (win: BrowserWindow) => { 76 | if (!isInit) { 77 | void init(); 78 | } 79 | 80 | const {rpc} = win; 81 | 82 | const onupdate = (ev: Event, releaseNotes: string, releaseName: string, date: Date, updateUrl: string) => { 83 | const releaseUrl = updateUrl || `https://github.com/vercel/hyper/releases/tag/${releaseName}`; 84 | rpc.emit('update available', {releaseNotes, releaseName, releaseUrl, canInstall: !isLinux}); 85 | }; 86 | 87 | if (isLinux) { 88 | autoUpdater.on('update-available', onupdate); 89 | } else { 90 | autoUpdater.on('update-downloaded', onupdate); 91 | } 92 | 93 | rpc.once('quit and install', () => { 94 | autoUpdater.quitAndInstall(); 95 | }); 96 | 97 | app.config.subscribe(async () => { 98 | const {updateChannel} = await getDecoratedConfigWithRetry(); 99 | const newUpdateIsCanary = isCanary(updateChannel); 100 | 101 | if (newUpdateIsCanary !== canaryUpdates) { 102 | const feedURL = buildFeedUrl(newUpdateIsCanary, version); 103 | 104 | autoUpdater.setFeedURL({url: feedURL}); 105 | void checkForUpdates(); 106 | 107 | canaryUpdates = newUpdateIsCanary; 108 | } 109 | }); 110 | 111 | win.on('close', () => { 112 | if (isLinux) { 113 | autoUpdater.removeListener('update-available', onupdate); 114 | } else { 115 | autoUpdater.removeListener('update-downloaded', onupdate); 116 | } 117 | }); 118 | }; 119 | 120 | export default updater; 121 | -------------------------------------------------------------------------------- /lib/reducers/sessions.ts: -------------------------------------------------------------------------------- 1 | import Immutable from 'seamless-immutable'; 2 | 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 | } from '../../typings/constants/sessions'; 15 | import type {sessionState, session, Mutable, ISessionReducer} from '../../typings/hyper'; 16 | import {decorateSessionsReducer} from '../utils/plugins'; 17 | 18 | const initialState: sessionState = 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 | cleared: false, 30 | search: false, 31 | shell: '', 32 | pid: null, 33 | profile: '' 34 | }; 35 | return Immutable(x).merge(obj); 36 | } 37 | 38 | function deleteSession(state: sessionState, 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: ISessionReducer = (state = initialState, action) => { 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 | profile: action.profile 58 | }) 59 | ); 60 | 61 | case SESSION_SET_ACTIVE: 62 | return state.set('activeUid', action.uid); 63 | 64 | case SESSION_SEARCH: 65 | return state.setIn(['sessions', action.uid, 'search'], action.value); 66 | 67 | case SESSION_CLEAR_ACTIVE: 68 | return state.merge( 69 | { 70 | sessions: { 71 | [state.activeUid!]: { 72 | cleared: true 73 | } 74 | } 75 | }, 76 | {deep: true} 77 | ); 78 | 79 | case SESSION_PTY_DATA: 80 | // we avoid a direct merge for perf reasons 81 | // as this is the most common action 82 | if (state.sessions[action.uid]?.cleared) { 83 | return state.merge( 84 | { 85 | sessions: { 86 | [action.uid]: { 87 | cleared: false 88 | } 89 | } 90 | }, 91 | {deep: true} 92 | ); 93 | } 94 | return state; 95 | 96 | case SESSION_PTY_EXIT: 97 | if (state.sessions[action.uid]) { 98 | return deleteSession(state, action.uid); 99 | } 100 | console.log('ignore pty exit: session removed by user'); 101 | return state; 102 | 103 | case SESSION_USER_EXIT: 104 | return deleteSession(state, action.uid); 105 | 106 | case SESSION_SET_XTERM_TITLE: 107 | return state.setIn( 108 | ['sessions', action.uid, 'title'], 109 | // we need to trim the title because `cmd.exe` 110 | // likes to report ' ' as the title 111 | action.title.trim() 112 | ); 113 | 114 | case SESSION_RESIZE: 115 | return state.setIn( 116 | ['sessions', action.uid], 117 | state.sessions[action.uid].merge({ 118 | rows: action.rows, 119 | cols: action.cols, 120 | resizeAt: action.now 121 | }) 122 | ); 123 | 124 | case SESSION_SET_CWD: 125 | if (state.activeUid) { 126 | return state.setIn(['sessions', state.activeUid, 'cwd'], action.cwd); 127 | } 128 | return state; 129 | 130 | default: 131 | return state; 132 | } 133 | }; 134 | 135 | export default decorateSessionsReducer(reducer); 136 | -------------------------------------------------------------------------------- /app/menus/menus/help.ts: -------------------------------------------------------------------------------- 1 | import {release} from 'os'; 2 | 3 | import {app, shell, dialog, clipboard} from 'electron'; 4 | import type {MenuItemConstructorOptions} from 'electron'; 5 | 6 | import {getConfig, getPlugins} from '../../config'; 7 | import {version} from '../../package.json'; 8 | 9 | const {arch, env, platform, versions} = process; 10 | 11 | const helpMenu = (commands: Record, showAbout: () => void): MenuItemConstructorOptions => { 12 | const submenu: MenuItemConstructorOptions[] = [ 13 | { 14 | label: `${app.name} Website`, 15 | click() { 16 | void shell.openExternal('https://hyper.is'); 17 | } 18 | }, 19 | { 20 | label: 'Report Issue', 21 | click(menuItem, focusedWindow) { 22 | const body = ` 28 | 29 | - [ ] Your Hyper.app version is **${version}**. Please verify you're using the [latest](https://github.com/vercel/hyper/releases/latest) Hyper.app version 30 | - [ ] I have searched the [issues](https://github.com/vercel/hyper/issues) of this repo and believe that this is not a duplicate 31 | --- 32 | - **Any relevant information from devtools?** _(CMD+OPTION+I on macOS, CTRL+SHIFT+I elsewhere)_: 33 | 34 | 35 | - **Is the issue reproducible in vanilla Hyper.app?** 36 | 37 | 38 | ## Issue 39 | 40 | 41 | 42 | 43 | 44 | 45 | --- 46 | 47 | - **${app.name} version**: ${env.TERM_PROGRAM_VERSION} "${app.getVersion()}" 48 | - **OS ARCH VERSION:** ${platform} ${arch} ${release()} 49 | - **Electron:** ${versions.electron} **LANG:** ${env.LANG} 50 | - **SHELL:** ${env.SHELL} **TERM:** ${env.TERM} 51 |
hyper.json contents 52 | 53 | \`\`\`json 54 | ${JSON.stringify(getConfig(), null, 2)} 55 | \`\`\` 56 |
57 |
plugins 58 | 59 | \`\`\`json 60 | ${JSON.stringify(getPlugins(), null, 2)} 61 | \`\`\` 62 |
`; 63 | 64 | const issueURL = `https://github.com/vercel/hyper/issues/new?body=${encodeURIComponent(body)}`; 65 | const copyAndSend = () => { 66 | clipboard.writeText(body); 67 | void shell.openExternal( 68 | `https://github.com/vercel/hyper/issues/new?body=${encodeURIComponent( 69 | '\n' 71 | )}` 72 | ); 73 | }; 74 | if (!focusedWindow) { 75 | copyAndSend(); 76 | } else if (issueURL.length > 6144) { 77 | void dialog 78 | .showMessageBox(focusedWindow, { 79 | message: 80 | 'There is too much data to send to GitHub directly. The data will be copied to the clipboard, ' + 81 | 'please paste it into the GitHub issue page that will open.', 82 | type: 'warning', 83 | buttons: ['OK', 'Cancel'] 84 | }) 85 | .then((result) => { 86 | if (result.response === 0) { 87 | copyAndSend(); 88 | } 89 | }); 90 | } else { 91 | void shell.openExternal(issueURL); 92 | } 93 | } 94 | } 95 | ]; 96 | 97 | if (process.platform !== 'darwin') { 98 | submenu.push( 99 | {type: 'separator'}, 100 | { 101 | label: 'About Hyper', 102 | click() { 103 | showAbout(); 104 | } 105 | } 106 | ); 107 | } 108 | return { 109 | role: 'help', 110 | submenu 111 | }; 112 | }; 113 | 114 | export default helpMenu; 115 | -------------------------------------------------------------------------------- /lib/components/notifications.tsx: -------------------------------------------------------------------------------- 1 | import React, {forwardRef} from 'react'; 2 | 3 | import type {NotificationsProps} from '../../typings/hyper'; 4 | import {decorate} from '../utils/plugins'; 5 | 6 | import Notification_ from './notification'; 7 | 8 | const Notification = decorate(Notification_, 'Notification'); 9 | 10 | const Notifications = forwardRef((props, ref) => { 11 | return ( 12 |
13 | {props.customChildrenBefore} 14 | {props.fontShowing && ( 15 | 23 | )} 24 | 25 | {props.resizeShowing && ( 26 | 34 | )} 35 | 36 | {props.messageShowing && ( 37 | 45 | {props.messageURL ? ( 46 | <> 47 | {props.messageText} ( 48 | { 51 | void window.require('electron').shell.openExternal(ev.currentTarget.href); 52 | ev.preventDefault(); 53 | }} 54 | href={props.messageURL} 55 | > 56 | more 57 | 58 | ) 59 | 60 | ) : null} 61 | 62 | )} 63 | 64 | {props.updateShowing && ( 65 | 73 | Version {props.updateVersion} ready. 74 | {props.updateNote && ` ${props.updateNote.trim().replace(/\.$/, '')}`} ( 75 | { 78 | void window.require('electron').shell.openExternal(ev.currentTarget.href); 79 | ev.preventDefault(); 80 | }} 81 | href={`https://github.com/vercel/hyper/releases/tag/${props.updateVersion}`} 82 | > 83 | notes 84 | 85 | ).{' '} 86 | {props.updateCanInstall ? ( 87 | 95 | Restart 96 | 97 | ) : ( 98 | { 106 | void window.require('electron').shell.openExternal(ev.currentTarget.href); 107 | ev.preventDefault(); 108 | }} 109 | href={props.updateReleaseUrl!} 110 | > 111 | Download 112 | 113 | )} 114 | .{' '} 115 | 116 | )} 117 | {props.customChildren} 118 | 119 | 126 |
127 | ); 128 | }); 129 | 130 | Notifications.displayName = 'Notifications'; 131 | 132 | export default Notifications; 133 | -------------------------------------------------------------------------------- /typings/constants/ui.d.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 | isMaximized: boolean; 73 | } 74 | export interface UIOpenFileAction { 75 | type: typeof UI_OPEN_FILE; 76 | } 77 | export interface UIOpenSshUrlAction { 78 | type: typeof UI_OPEN_SSH_URL; 79 | } 80 | export interface UIOpenHamburgerMenuAction { 81 | type: typeof UI_OPEN_HAMBURGER_MENU; 82 | } 83 | export interface UIWindowMinimizeAction { 84 | type: typeof UI_WINDOW_MINIMIZE; 85 | } 86 | export interface UIWindowCloseAction { 87 | type: typeof UI_WINDOW_CLOSE; 88 | } 89 | export interface UIEnterFullscreenAction { 90 | type: typeof UI_ENTER_FULLSCREEN; 91 | } 92 | export interface UILeaveFullscreenAction { 93 | type: typeof UI_LEAVE_FULLSCREEN; 94 | } 95 | export interface UIContextmenuOpenAction { 96 | type: typeof UI_CONTEXTMENU_OPEN; 97 | } 98 | export interface UICommandExecAction { 99 | type: typeof UI_COMMAND_EXEC; 100 | command: string; 101 | } 102 | 103 | export type UIActions = 104 | | UIFontSizeSetAction 105 | | UIFontSizeIncrAction 106 | | UIFontSizeDecrAction 107 | | UIFontSizeResetAction 108 | | UIFontSmoothingSetAction 109 | | UIMoveLeftAction 110 | | UIMoveRightAction 111 | | UIMoveToAction 112 | | UIMoveNextPaneAction 113 | | UIMovePrevPaneAction 114 | | UIShowPreferencesAction 115 | | UIWindowMoveAction 116 | | UIWindowMaximizeAction 117 | | UIWindowUnmaximizeAction 118 | | UIWindowGeometryChangedAction 119 | | UIOpenFileAction 120 | | UIOpenSshUrlAction 121 | | UIOpenHamburgerMenuAction 122 | | UIWindowMinimizeAction 123 | | UIWindowCloseAction 124 | | UIEnterFullscreenAction 125 | | UILeaveFullscreenAction 126 | | UIContextmenuOpenAction 127 | | UICommandExecAction; 128 | -------------------------------------------------------------------------------- /app/menus/menus/edit.ts: -------------------------------------------------------------------------------- 1 | import type {BrowserWindow, MenuItemConstructorOptions} from 'electron'; 2 | 3 | const editMenu = ( 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 | registerAccelerator: true 36 | }, 37 | { 38 | label: 'Select All', 39 | accelerator: commandKeys['editor:selectAll'], 40 | click(item, focusedWindow) { 41 | execCommand('editor:selectAll', focusedWindow); 42 | } 43 | }, 44 | { 45 | type: 'separator' 46 | }, 47 | { 48 | label: 'Move to...', 49 | submenu: [ 50 | { 51 | label: 'Previous word', 52 | accelerator: commandKeys['editor:movePreviousWord'], 53 | click(item, focusedWindow) { 54 | execCommand('editor:movePreviousWord', focusedWindow); 55 | } 56 | }, 57 | { 58 | label: 'Next word', 59 | accelerator: commandKeys['editor:moveNextWord'], 60 | click(item, focusedWindow) { 61 | execCommand('editor:moveNextWord', focusedWindow); 62 | } 63 | }, 64 | { 65 | label: 'Line beginning', 66 | accelerator: commandKeys['editor:moveBeginningLine'], 67 | click(item, focusedWindow) { 68 | execCommand('editor:moveBeginningLine', focusedWindow); 69 | } 70 | }, 71 | { 72 | label: 'Line end', 73 | accelerator: commandKeys['editor:moveEndLine'], 74 | click(item, focusedWindow) { 75 | execCommand('editor:moveEndLine', focusedWindow); 76 | } 77 | } 78 | ] 79 | }, 80 | { 81 | label: 'Delete...', 82 | submenu: [ 83 | { 84 | label: 'Previous word', 85 | accelerator: commandKeys['editor:deletePreviousWord'], 86 | click(item, focusedWindow) { 87 | execCommand('editor:deletePreviousWord', focusedWindow); 88 | } 89 | }, 90 | { 91 | label: 'Next word', 92 | accelerator: commandKeys['editor:deleteNextWord'], 93 | click(item, focusedWindow) { 94 | execCommand('editor:deleteNextWord', focusedWindow); 95 | } 96 | }, 97 | { 98 | label: 'Line beginning', 99 | accelerator: commandKeys['editor:deleteBeginningLine'], 100 | click(item, focusedWindow) { 101 | execCommand('editor:deleteBeginningLine', focusedWindow); 102 | } 103 | }, 104 | { 105 | label: 'Line end', 106 | accelerator: commandKeys['editor:deleteEndLine'], 107 | click(item, focusedWindow) { 108 | execCommand('editor:deleteEndLine', focusedWindow); 109 | } 110 | } 111 | ] 112 | }, 113 | { 114 | type: 'separator' 115 | }, 116 | { 117 | label: 'Clear Buffer', 118 | accelerator: commandKeys['editor:clearBuffer'], 119 | click(item, focusedWindow) { 120 | execCommand('editor:clearBuffer', focusedWindow); 121 | } 122 | }, 123 | { 124 | label: 'Search', 125 | accelerator: commandKeys['editor:search'], 126 | click(item, focusedWindow) { 127 | execCommand('editor:search', focusedWindow); 128 | } 129 | } 130 | ]; 131 | 132 | if (process.platform !== 'darwin') { 133 | submenu.push( 134 | {type: 'separator'}, 135 | { 136 | label: 'Preferences...', 137 | accelerator: commandKeys['window:preferences'], 138 | click() { 139 | execCommand('window:preferences'); 140 | } 141 | } 142 | ); 143 | } 144 | 145 | return { 146 | label: 'Edit', 147 | submenu 148 | }; 149 | }; 150 | 151 | export default editMenu; 152 | -------------------------------------------------------------------------------- /lib/components/new-tab.tsx: -------------------------------------------------------------------------------- 1 | import React, {useRef, useState} from 'react'; 2 | 3 | import {VscChevronDown} from '@react-icons/all-files/vsc/VscChevronDown'; 4 | import useClickAway from 'react-use/lib/useClickAway'; 5 | 6 | import type {configOptions} from '../../typings/config'; 7 | 8 | interface Props { 9 | defaultProfile: string; 10 | profiles: configOptions['profiles']; 11 | openNewTab: (name: string) => void; 12 | backgroundColor: string; 13 | borderColor: string; 14 | tabsVisible: boolean; 15 | } 16 | const isMac = /Mac/.test(navigator.userAgent); 17 | 18 | const DropdownButton = ({defaultProfile, profiles, openNewTab, backgroundColor, borderColor, tabsVisible}: Props) => { 19 | const [dropdownOpen, setDropdownOpen] = useState(false); 20 | const ref = useRef(null); 21 | 22 | const toggleDropdown = () => { 23 | setDropdownOpen(!dropdownOpen); 24 | }; 25 | 26 | useClickAway(ref, () => { 27 | setDropdownOpen(false); 28 | }); 29 | 30 | return ( 31 |
e.stopPropagation()} 37 | onBlur={() => setDropdownOpen(false)} 38 | > 39 | 40 | 41 | {dropdownOpen && ( 42 |
    50 | {profiles.map((profile) => ( 51 |
  • { 54 | openNewTab(profile.name); 55 | setDropdownOpen(false); 56 | }} 57 | className={`profile_dropdown_item ${ 58 | profile.name === defaultProfile && profiles.length > 1 ? 'profile_dropdown_item_default' : '' 59 | }`} 60 | > 61 | {profile.name} 62 |
  • 63 | ))} 64 |
65 | )} 66 | 67 | 145 |
146 | ); 147 | }; 148 | 149 | export default DropdownButton; 150 | -------------------------------------------------------------------------------- /cli/api.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line eslint-comments/disable-enable-pair 2 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 3 | import fs from 'fs'; 4 | import os from 'os'; 5 | import path from 'path'; 6 | 7 | import got from 'got'; 8 | import registryUrlModule from 'registry-url'; 9 | 10 | const registryUrl = registryUrlModule(); 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 = process.env.XDG_CONFIG_HOME 15 | ? path.join(process.env.XDG_CONFIG_HOME, 'Hyper') 16 | : process.platform === 'win32' 17 | ? path.join(process.env.APPDATA!, 'Hyper') 18 | : path.join(os.homedir(), '.config', 'Hyper'); 19 | 20 | const devConfigFileName = path.join(__dirname, `../hyper.json`); 21 | 22 | const fileName = 23 | process.env.NODE_ENV !== 'production' && fs.existsSync(devConfigFileName) 24 | ? devConfigFileName 25 | : path.join(applicationDirectory, 'hyper.json'); 26 | 27 | /** 28 | * We need to make sure the file reading and parsing is lazy so that failure to 29 | * statically analyze the hyper configuration isn't fatal for all kinds of 30 | * subcommands. We can use memoization to make reading and parsing lazy. 31 | */ 32 | function memoize any>(fn: T): T { 33 | let hasResult = false; 34 | let result: any; 35 | return ((...args: Parameters) => { 36 | if (!hasResult) { 37 | result = fn(...args); 38 | hasResult = true; 39 | } 40 | return result; 41 | }) as T; 42 | } 43 | 44 | const getFileContents = memoize(() => { 45 | return fs.readFileSync(fileName, 'utf8'); 46 | }); 47 | 48 | const getParsedFile = memoize(() => JSON.parse(getFileContents())); 49 | 50 | const getPluginsByKey = (key: string): any[] => getParsedFile()[key] || []; 51 | 52 | const getPlugins = memoize(() => { 53 | return getPluginsByKey('plugins'); 54 | }); 55 | 56 | const getLocalPlugins = memoize(() => { 57 | return getPluginsByKey('localPlugins'); 58 | }); 59 | 60 | function exists() { 61 | return getFileContents() !== undefined; 62 | } 63 | 64 | function isInstalled(plugin: string, locally?: boolean) { 65 | const array = locally ? getLocalPlugins() : getPlugins(); 66 | if (array && Array.isArray(array)) { 67 | return array.includes(plugin); 68 | } 69 | return false; 70 | } 71 | 72 | function save(config: any) { 73 | return fs.writeFileSync(fileName, JSON.stringify(config, null, 2), 'utf8'); 74 | } 75 | 76 | function getPackageName(plugin: string) { 77 | const isScoped = plugin[0] === '@'; 78 | const nameWithoutVersion = plugin.split('#')[0]; 79 | 80 | if (isScoped) { 81 | return `@${nameWithoutVersion.split('@')[1].replace('/', '%2f')}`; 82 | } 83 | 84 | return nameWithoutVersion.split('@')[0]; 85 | } 86 | 87 | function existsOnNpm(plugin: string) { 88 | const name = getPackageName(plugin); 89 | return got 90 | .get(registryUrl + name.toLowerCase(), {timeout: {request: 10000}, responseType: 'json'}) 91 | .then((res) => { 92 | if (!res.body.versions) { 93 | return Promise.reject(res); 94 | } else { 95 | return res; 96 | } 97 | }); 98 | } 99 | 100 | function install(plugin: string, locally?: boolean) { 101 | const array = locally ? getLocalPlugins() : getPlugins(); 102 | return existsOnNpm(plugin) 103 | .catch((err: any) => { 104 | const {statusCode} = err; 105 | if (statusCode && (statusCode === 404 || statusCode === 200)) { 106 | return Promise.reject(`${plugin} not found on npm`); 107 | } 108 | return Promise.reject(`${err.message}\nPlugin check failed. Check your internet connection or retry later.`); 109 | }) 110 | .then(() => { 111 | if (isInstalled(plugin, locally)) { 112 | return Promise.reject(`${plugin} is already installed`); 113 | } 114 | 115 | const config = getParsedFile(); 116 | config[locally ? 'localPlugins' : 'plugins'] = [...array, plugin]; 117 | save(config); 118 | }); 119 | } 120 | 121 | async function uninstall(plugin: string) { 122 | if (!isInstalled(plugin)) { 123 | return Promise.reject(`${plugin} is not installed`); 124 | } 125 | 126 | const config = getParsedFile(); 127 | config.plugins = getPlugins().filter((p) => p !== plugin); 128 | save(config); 129 | } 130 | 131 | function list() { 132 | if (getPlugins().length > 0) { 133 | return getPlugins().join('\n'); 134 | } 135 | return false; 136 | } 137 | 138 | export const configPath = fileName; 139 | export {exists, existsOnNpm, isInstalled, install, uninstall, list}; 140 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "react", 4 | "prettier", 5 | "@typescript-eslint", 6 | "eslint-comments", 7 | "lodash", 8 | "import" 9 | ], 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:react/recommended", 13 | "plugin:prettier/recommended", 14 | "plugin:eslint-comments/recommended" 15 | ], 16 | "parser": "@typescript-eslint/parser", 17 | "parserOptions": { 18 | "sourceType": "module", 19 | "ecmaFeatures": { 20 | "jsx": true, 21 | "impliedStrict": true, 22 | "experimentalObjectRestSpread": true 23 | }, 24 | "allowImportExportEverywhere": true, 25 | "project": [ 26 | "./tsconfig.eslint.json" 27 | ] 28 | }, 29 | "env": { 30 | "es6": true, 31 | "browser": true, 32 | "node": true 33 | }, 34 | "settings": { 35 | "react": { 36 | "version": "detect" 37 | }, 38 | "import/resolver": { 39 | "typescript": {} 40 | }, 41 | "import/internal-regex": "^(electron|react)$" 42 | }, 43 | "rules": { 44 | "func-names": [ 45 | "error", 46 | "as-needed" 47 | ], 48 | "no-shadow": "error", 49 | "no-extra-semi": 0, 50 | "react/prop-types": 0, 51 | "react/react-in-jsx-scope": 0, 52 | "react/no-unescaped-entities": 0, 53 | "react/jsx-no-target-blank": 0, 54 | "react/no-string-refs": 0, 55 | "prettier/prettier": [ 56 | "error", 57 | { 58 | "printWidth": 120, 59 | "tabWidth": 2, 60 | "singleQuote": true, 61 | "trailingComma": "none", 62 | "bracketSpacing": false, 63 | "semi": true, 64 | "useTabs": false, 65 | "bracketSameLine": false 66 | } 67 | ], 68 | "eslint-comments/no-unused-disable": "error", 69 | "react/no-unknown-property":[ 70 | "error", 71 | { 72 | "ignore": [ 73 | "jsx", 74 | "global" 75 | ] 76 | } 77 | ] 78 | }, 79 | "overrides": [ 80 | { 81 | "files": [ 82 | "**.ts", 83 | "**.tsx" 84 | ], 85 | "extends": [ 86 | "plugin:@typescript-eslint/recommended", 87 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 88 | "prettier" 89 | ], 90 | "rules": { 91 | "@typescript-eslint/explicit-function-return-type": "off", 92 | "@typescript-eslint/explicit-module-boundary-types": "off", 93 | "@typescript-eslint/no-explicit-any": "off", 94 | "@typescript-eslint/no-non-null-assertion": "off", 95 | "@typescript-eslint/prefer-optional-chain": "error", 96 | "@typescript-eslint/ban-types": "off", 97 | "no-shadow": "off", 98 | "@typescript-eslint/no-shadow": ["error"], 99 | "@typescript-eslint/no-unsafe-assignment": "off", 100 | "@typescript-eslint/no-unsafe-member-access": "off", 101 | "@typescript-eslint/restrict-template-expressions": "off", 102 | "@typescript-eslint/consistent-type-imports": [ "error", { "disallowTypeAnnotations": false } ], 103 | "lodash/prop-shorthand": [ "error", "always" ], 104 | "lodash/import-scope": [ "error", "method" ], 105 | "lodash/collection-return": "error", 106 | "lodash/collection-method-value": "error", 107 | "import/no-extraneous-dependencies": "error", 108 | "import/no-anonymous-default-export": "error", 109 | "import/order": [ 110 | "error", 111 | { 112 | "groups": [ 113 | "builtin", 114 | "internal", 115 | "external", 116 | "parent", 117 | "sibling", 118 | "index" 119 | ], 120 | "newlines-between": "always", 121 | "alphabetize": { 122 | "order": "asc", 123 | "orderImportKind": "desc", 124 | "caseInsensitive": true 125 | } 126 | } 127 | ] 128 | } 129 | }, 130 | { 131 | "extends": [ 132 | "plugin:jsonc/recommended-with-json", 133 | "plugin:json-schema-validator/recommended" 134 | ], 135 | "files": [ 136 | "*.json" 137 | ], 138 | "parser": "jsonc-eslint-parser", 139 | "plugins": [ 140 | "jsonc", 141 | "json-schema-validator" 142 | ], 143 | "rules": { 144 | "jsonc/array-element-newline": [ 145 | "error", 146 | "consistent" 147 | ], 148 | "jsonc/array-bracket-newline": [ 149 | "error", 150 | "consistent" 151 | ], 152 | "jsonc/indent": [ 153 | "error", 154 | 2 155 | ], 156 | "prettier/prettier": "off", 157 | "json-schema-validator/no-invalid": "error" 158 | } 159 | } 160 | ] 161 | } 162 | -------------------------------------------------------------------------------- /lib/components/tab.tsx: -------------------------------------------------------------------------------- 1 | import React, {forwardRef} from 'react'; 2 | 3 | import type {TabProps} from '../../typings/hyper'; 4 | 5 | const Tab = forwardRef((props, ref) => { 6 | const handleClick = (event: React.MouseEvent) => { 7 | const isLeftClick = event.nativeEvent.which === 1; 8 | 9 | if (isLeftClick && !props.isActive) { 10 | props.onSelect(); 11 | } 12 | }; 13 | 14 | const handleMouseUp = (event: React.MouseEvent) => { 15 | const isMiddleClick = event.nativeEvent.which === 2; 16 | 17 | if (isMiddleClick) { 18 | props.onClose(); 19 | } 20 | }; 21 | 22 | const {isActive, isFirst, isLast, borderColor, hasActivity} = props; 23 | 24 | return ( 25 | <> 26 |
  • 34 | {props.customChildrenBefore} 35 | 40 | 41 | {props.text} 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {props.customChildren} 50 |
  • 51 | 52 | 162 | 163 | ); 164 | }); 165 | 166 | Tab.displayName = 'Tab'; 167 | 168 | export default Tab; 169 | -------------------------------------------------------------------------------- /electron-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/electron-builder", 3 | "appId": "co.zeit.hyper", 4 | "afterSign": "./bin/notarize.js", 5 | "afterPack": "./bin/cp-snapshot.js", 6 | "directories": { 7 | "app": "target" 8 | }, 9 | "extraResources": [ 10 | "./bin/yarn-standalone.js", 11 | "./bin/cli.js", 12 | { 13 | "from": "./build/${os}/", 14 | "to": "./bin/", 15 | "filter": [ 16 | "hyper*" 17 | ] 18 | } 19 | ], 20 | "artifactName": "${productName}-${version}-${arch}.${ext}", 21 | "linux": { 22 | "category": "TerminalEmulator", 23 | "target": [ 24 | "deb", 25 | "AppImage", 26 | "rpm", 27 | "snap", 28 | "pacman" 29 | ] 30 | }, 31 | "win": { 32 | "target": { 33 | "target": "nsis", 34 | "arch": [ 35 | "x64", 36 | "arm64" 37 | ] 38 | }, 39 | "rfc3161TimeStampServer": "http://timestamp.comodoca.com" 40 | }, 41 | "nsis": { 42 | "include": "build/win/installer.nsh", 43 | "oneClick": false, 44 | "perMachine": false, 45 | "allowToChangeInstallationDirectory": true 46 | }, 47 | "mac": { 48 | "target": { 49 | "target": "default", 50 | "arch": [ 51 | "x64", 52 | "arm64" 53 | ] 54 | }, 55 | "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", 56 | "category": "public.app-category.developer-tools", 57 | "entitlements": "./build/mac/entitlements.plist", 58 | "entitlementsInherit": "./build/mac/entitlements.plist", 59 | "extendInfo": { 60 | "CFBundleDocumentTypes": [ 61 | { 62 | "CFBundleTypeName": "Folders", 63 | "CFBundleTypeRole": "Viewer", 64 | "LSHandlerRank": "Alternate", 65 | "LSItemContentTypes": [ 66 | "public.folder", 67 | "com.apple.bundle", 68 | "com.apple.package", 69 | "com.apple.resolvable" 70 | ] 71 | }, 72 | { 73 | "CFBundleTypeName": "UnixExecutables", 74 | "CFBundleTypeRole": "Shell", 75 | "LSHandlerRank": "Alternate", 76 | "LSItemContentTypes": [ 77 | "public.unix-executable" 78 | ] 79 | } 80 | ], 81 | "NSAppleEventsUsageDescription": "An application in Hyper wants to use AppleScript.", 82 | "NSCalendarsUsageDescription": "An application in Hyper wants to access Calendar data.", 83 | "NSCameraUsageDescription": "An application in Hyper wants to use the Camera.", 84 | "NSContactsUsageDescription": "An application in Hyper wants to access your Contacts.", 85 | "NSDesktopFolderUsageDescription": "An application in Hyper wants to access the Desktop folder.", 86 | "NSDocumentsFolderUsageDescription": "An application in Hyper wants to access the Documents folder.", 87 | "NSDownloadsFolderUsageDescription": "An application in Hyper wants to access the Downloads folder.", 88 | "NSFileProviderDomainUsageDescription": "An application in Hyper wants to access files managed by a file provider.", 89 | "NSFileProviderPresenceUsageDescription": "An application in Hyper wants to be informed when other apps access files that it manages.", 90 | "NSLocationUsageDescription": "An application in Hyper wants to access your location information.", 91 | "NSMicrophoneUsageDescription": "An application in Hyper wants to use your microphone.", 92 | "NSMotionUsageDescription": "An application in Hyper wants to use the device’s accelerometer.", 93 | "NSNetworkVolumesUsageDescription": "An application in Hyper wants to access files on a network volume.", 94 | "NSPhotoLibraryUsageDescription": "An application in Hyper wants to access the photo library.", 95 | "NSRemindersUsageDescription": "An application in Hyper wants to access your reminders.", 96 | "NSRemovableVolumesUsageDescription": "An application in Hyper wants to access files on a removable volume.", 97 | "NSSpeechRecognitionUsageDescription": "An application in Hyper wants to send user data to Apple’s speech recognition servers.", 98 | "NSSystemAdministrationUsageDescription": "The operation being performed by an application in Hyper requires elevated permission." 99 | }, 100 | "darkModeSupport": true 101 | }, 102 | "deb": { 103 | "compression": "bzip2", 104 | "afterInstall": "./build/linux/after-install.tpl" 105 | }, 106 | "rpm": { 107 | "afterInstall": "./build/linux/after-install.tpl", 108 | "fpm": [ 109 | "--rpm-rpmbuild-define", 110 | "_build_id_links none" 111 | ] 112 | }, 113 | "snap": { 114 | "confinement": "classic", 115 | "publish": "github" 116 | }, 117 | "protocols": { 118 | "name": "ssh URL", 119 | "schemes": [ 120 | "ssh" 121 | ] 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /app/config.ts: -------------------------------------------------------------------------------- 1 | import {app} from 'electron'; 2 | 3 | import chokidar from 'chokidar'; 4 | 5 | import type {parsedConfig, configOptions} from '../typings/config'; 6 | 7 | import {_import, getDefaultConfig} from './config/import'; 8 | import _openConfig from './config/open'; 9 | import {cfgPath, cfgDir} from './config/paths'; 10 | import notify from './notify'; 11 | import {getColorMap} from './utils/colors'; 12 | 13 | const watchers: Function[] = []; 14 | let cfg: parsedConfig = {} as any; 15 | let _watcher: chokidar.FSWatcher; 16 | 17 | export const getDeprecatedCSS = (config: configOptions) => { 18 | const deprecated: string[] = []; 19 | const deprecatedCSS = ['x-screen', 'x-row', 'cursor-node', '::selection']; 20 | deprecatedCSS.forEach((css) => { 21 | if (config.css?.includes(css) || config.termCSS?.includes(css)) { 22 | deprecated.push(css); 23 | } 24 | }); 25 | return deprecated; 26 | }; 27 | 28 | const checkDeprecatedConfig = () => { 29 | if (!cfg.config) { 30 | return; 31 | } 32 | const deprecated = getDeprecatedCSS(cfg.config); 33 | if (deprecated.length === 0) { 34 | return; 35 | } 36 | const deprecatedStr = deprecated.join(', '); 37 | notify('Configuration warning', `Your configuration uses some deprecated CSS classes (${deprecatedStr})`); 38 | }; 39 | 40 | const _watch = () => { 41 | if (_watcher) { 42 | return; 43 | } 44 | 45 | const onChange = () => { 46 | // Need to wait 100ms to ensure that write is complete 47 | setTimeout(() => { 48 | cfg = _import(); 49 | notify('Configuration updated', 'Hyper configuration reloaded!'); 50 | watchers.forEach((fn) => { 51 | fn(); 52 | }); 53 | checkDeprecatedConfig(); 54 | }, 100); 55 | }; 56 | 57 | _watcher = chokidar.watch(cfgPath); 58 | _watcher.on('change', onChange); 59 | _watcher.on('error', (error) => { 60 | console.error('error watching config', error); 61 | }); 62 | 63 | app.on('before-quit', () => { 64 | if (Object.keys(_watcher.getWatched()).length > 0) { 65 | _watcher.close().catch((err) => { 66 | console.warn(err); 67 | }); 68 | } 69 | }); 70 | }; 71 | 72 | export const subscribe = (fn: Function) => { 73 | watchers.push(fn); 74 | return () => { 75 | watchers.splice(watchers.indexOf(fn), 1); 76 | }; 77 | }; 78 | 79 | export const getConfigDir = () => { 80 | // expose config directory to load plugin from the right place 81 | return cfgDir; 82 | }; 83 | 84 | export const getDefaultProfile = () => { 85 | return cfg.config.defaultProfile || cfg.config.profiles[0]?.name || 'default'; 86 | }; 87 | 88 | // get config for the default profile, keeping it for backward compatibility 89 | export const getConfig = () => { 90 | return getProfileConfig(getDefaultProfile()); 91 | }; 92 | 93 | export const getProfiles = () => { 94 | return cfg.config.profiles; 95 | }; 96 | 97 | export const getProfileConfig = (profileName: string): configOptions => { 98 | const {profiles, defaultProfile, ...baseConfig} = cfg.config; 99 | const profileConfig = profiles.find((p) => p.name === profileName)?.config || {}; 100 | for (const key in profileConfig) { 101 | if (typeof baseConfig[key] === 'object' && !Array.isArray(baseConfig[key])) { 102 | baseConfig[key] = {...baseConfig[key], ...profileConfig[key]}; 103 | } else { 104 | baseConfig[key] = profileConfig[key]; 105 | } 106 | } 107 | return {...baseConfig, defaultProfile, profiles}; 108 | }; 109 | 110 | export const openConfig = () => { 111 | return _openConfig(); 112 | }; 113 | 114 | export const getPlugins = (): {plugins: string[]; localPlugins: string[]} => { 115 | return { 116 | plugins: cfg.plugins, 117 | localPlugins: cfg.localPlugins 118 | }; 119 | }; 120 | 121 | export const getKeymaps = () => { 122 | return cfg.keymaps; 123 | }; 124 | 125 | export const setup = () => { 126 | cfg = _import(); 127 | _watch(); 128 | checkDeprecatedConfig(); 129 | }; 130 | 131 | export {get as getWin, recordState as winRecord, defaults as windowDefaults} from './config/windows'; 132 | 133 | export const fixConfigDefaults = (decoratedConfig: configOptions) => { 134 | const defaultConfig = getDefaultConfig().config!; 135 | decoratedConfig.colors = getColorMap(decoratedConfig.colors) || {}; 136 | // We must have default colors for xterm css. 137 | decoratedConfig.colors = {...defaultConfig.colors, ...decoratedConfig.colors}; 138 | return decoratedConfig; 139 | }; 140 | 141 | export const htermConfigTranslate = (config: configOptions) => { 142 | const cssReplacements: Record = { 143 | 'x-screen x-row([ {.[])': '.xterm-rows > div$1', 144 | '.cursor-node([ {.[])': '.terminal-cursor$1', 145 | '::selection([ {.[])': '.terminal .xterm-selection div$1', 146 | 'x-screen a([ {.[])': '.terminal a$1', 147 | 'x-row a([ {.[])': '.terminal a$1' 148 | }; 149 | Object.keys(cssReplacements).forEach((pattern) => { 150 | const searchvalue = new RegExp(pattern, 'g'); 151 | const newvalue = cssReplacements[pattern]; 152 | config.css = config.css?.replace(searchvalue, newvalue); 153 | config.termCSS = config.termCSS?.replace(searchvalue, newvalue); 154 | }); 155 | return config; 156 | }; 157 | -------------------------------------------------------------------------------- /lib/actions/sessions.ts: -------------------------------------------------------------------------------- 1 | import type {Session} from '../../typings/common'; 2 | import { 3 | SESSION_ADD, 4 | SESSION_RESIZE, 5 | SESSION_REQUEST, 6 | SESSION_ADD_DATA, 7 | SESSION_PTY_DATA, 8 | SESSION_PTY_EXIT, 9 | SESSION_USER_EXIT, 10 | SESSION_SET_ACTIVE, 11 | SESSION_CLEAR_ACTIVE, 12 | SESSION_USER_DATA, 13 | SESSION_SET_XTERM_TITLE, 14 | SESSION_SEARCH 15 | } from '../../typings/constants/sessions'; 16 | import type {HyperState, HyperDispatch, HyperActions} from '../../typings/hyper'; 17 | import rpc from '../rpc'; 18 | import {keys} from '../utils/object'; 19 | import findBySession from '../utils/term-groups'; 20 | 21 | export function addSession({uid, shell, pid, cols = null, rows = null, splitDirection, activeUid, profile}: 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 | profile 36 | }); 37 | }; 38 | } 39 | 40 | export function requestSession(profile: string | undefined) { 41 | return (dispatch: HyperDispatch, getState: () => HyperState) => { 42 | dispatch({ 43 | type: SESSION_REQUEST, 44 | effect: () => { 45 | const {ui} = getState(); 46 | const {cwd} = ui; 47 | rpc.emit('new', {cwd, profile}); 48 | } 49 | }); 50 | }; 51 | } 52 | 53 | export function addSessionData(uid: string, data: string) { 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 openSearch(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 | value: true 145 | }); 146 | }; 147 | } 148 | 149 | export function closeSearch(uid?: string, keyEvent?: any) { 150 | return (dispatch: HyperDispatch, getState: () => HyperState) => { 151 | const targetUid = uid || getState().sessions.activeUid!; 152 | if (getState().sessions.sessions[targetUid]?.search) { 153 | dispatch({ 154 | type: SESSION_SEARCH, 155 | uid: targetUid, 156 | value: false 157 | }); 158 | } else { 159 | if (keyEvent) { 160 | keyEvent.catched = false; 161 | } 162 | } 163 | }; 164 | } 165 | 166 | export function sendSessionData(uid: string | null, data: string, escaped?: boolean) { 167 | return (dispatch: HyperDispatch, getState: () => HyperState) => { 168 | dispatch({ 169 | type: SESSION_USER_DATA, 170 | data, 171 | effect() { 172 | // If no uid is passed, data is sent to the active session. 173 | const targetUid = uid || getState().sessions.activeUid; 174 | 175 | rpc.emit('data', {uid: targetUid, data, escaped}); 176 | } 177 | }); 178 | }; 179 | } 180 | -------------------------------------------------------------------------------- /typings/common.d.ts: -------------------------------------------------------------------------------- 1 | import type {ExecFileOptions, ExecOptions} from 'child_process'; 2 | 3 | import type {IpcMain, IpcRenderer} from 'electron'; 4 | 5 | import type parseUrl from 'parse-url'; 6 | 7 | import type {configOptions} from './config'; 8 | 9 | export type Session = { 10 | uid: string; 11 | rows?: number | null; 12 | cols?: number | null; 13 | splitDirection?: 'HORIZONTAL' | 'VERTICAL'; 14 | shell: string | null; 15 | pid: number | null; 16 | activeUid?: string; 17 | profile: string; 18 | }; 19 | 20 | export type sessionExtraOptions = { 21 | cwd?: string; 22 | splitDirection?: 'HORIZONTAL' | 'VERTICAL'; 23 | activeUid?: string | null; 24 | isNewGroup?: boolean; 25 | rows?: number; 26 | cols?: number; 27 | shell?: string; 28 | shellArgs?: string[]; 29 | profile?: string; 30 | }; 31 | 32 | export type MainEvents = { 33 | close: never; 34 | command: string; 35 | data: {uid: string | null; data: string; escaped?: boolean}; 36 | exit: {uid: string}; 37 | 'info renderer': {uid: string; type: string}; 38 | init: null; 39 | maximize: never; 40 | minimize: never; 41 | new: sessionExtraOptions; 42 | 'open context menu': string; 43 | 'open external': {url: string}; 44 | 'open hamburger menu': {x: number; y: number}; 45 | 'quit and install': never; 46 | resize: {uid: string; cols: number; rows: number}; 47 | unmaximize: never; 48 | }; 49 | 50 | export type RendererEvents = { 51 | ready: never; 52 | 'add notification': {text: string; url: string; dismissable: boolean}; 53 | 'update available': {releaseNotes: string; releaseName: string; releaseUrl: string; canInstall: boolean}; 54 | 'open ssh': ReturnType; 55 | 'open file': {path: string}; 56 | 'move jump req': number | 'last'; 57 | 'reset fontSize req': never; 58 | 'move left req': never; 59 | 'move right req': never; 60 | 'prev pane req': never; 61 | 'decrease fontSize req': never; 62 | 'increase fontSize req': never; 63 | 'next pane req': never; 64 | 'session break req': never; 65 | 'session quit req': never; 66 | 'session search close': never; 67 | 'session search': never; 68 | 'session stop req': never; 69 | 'session tmux req': never; 70 | 'session del line beginning req': never; 71 | 'session del line end req': never; 72 | 'session del word left req': never; 73 | 'session del word right req': never; 74 | 'session move line beginning req': never; 75 | 'session move line end req': never; 76 | 'session move word left req': never; 77 | 'session move word right req': never; 78 | 'term selectAll': never; 79 | reload: never; 80 | 'session clear req': never; 81 | 'split request horizontal': {activeUid?: string; profile?: string}; 82 | 'split request vertical': {activeUid?: string; profile?: string}; 83 | 'termgroup add req': {activeUid?: string; profile?: string}; 84 | 'termgroup close req': never; 85 | 'session add': Session; 86 | 'session data': string; 87 | 'session exit': {uid: string}; 88 | 'windowGeometry change': {isMaximized: boolean}; 89 | move: {bounds: {x: number; y: number}}; 90 | 'enter full screen': never; 91 | 'leave full screen': never; 92 | 'session data send': {uid: string | null; data: string; escaped?: boolean}; 93 | }; 94 | 95 | /** 96 | * Get keys of T where the value is not never 97 | */ 98 | export type FilterNever = {[K in keyof T]: T[K] extends never ? never : K}[keyof T]; 99 | 100 | export interface TypedEmitter { 101 | on(event: E, listener: (args: Events[E]) => void): this; 102 | once(event: E, listener: (args: Events[E]) => void): this; 103 | emit>>(event: E): boolean; 104 | emit>(event: E, data: Events[E]): boolean; 105 | emit(event: E, data?: Events[E]): boolean; 106 | removeListener(event: E, listener: (args: Events[E]) => void): this; 107 | removeAllListeners(event?: E): this; 108 | } 109 | 110 | type OptionalPromise = T | Promise; 111 | 112 | export type IpcCommands = { 113 | 'child_process.exec': (command: string, options: ExecOptions) => {stdout: string; stderr: string}; 114 | 'child_process.execFile': ( 115 | file: string, 116 | args: string[], 117 | options: ExecFileOptions 118 | ) => { 119 | stdout: string; 120 | stderr: string; 121 | }; 122 | getLoadedPluginVersions: () => {name: string; version: string}[]; 123 | getPaths: () => {plugins: string[]; localPlugins: string[]}; 124 | getBasePaths: () => {path: string; localPath: string}; 125 | getDeprecatedConfig: () => Record; 126 | getDecoratedConfig: (profile: string) => configOptions; 127 | getDecoratedKeymaps: () => Record; 128 | }; 129 | 130 | export interface IpcMainWithCommands extends IpcMain { 131 | handle( 132 | channel: E, 133 | listener: ( 134 | event: Electron.IpcMainInvokeEvent, 135 | ...args: Parameters 136 | ) => OptionalPromise> 137 | ): void; 138 | } 139 | 140 | export interface IpcRendererWithCommands extends IpcRenderer { 141 | invoke( 142 | channel: E, 143 | ...args: Parameters 144 | ): Promise>; 145 | } 146 | -------------------------------------------------------------------------------- /lib/containers/hyper.tsx: -------------------------------------------------------------------------------- 1 | import React, {forwardRef, useEffect, useRef} from 'react'; 2 | 3 | import Mousetrap from 'mousetrap'; 4 | import type {MousetrapInstance} from 'mousetrap'; 5 | import stylis from 'stylis'; 6 | 7 | import type {HyperState, HyperProps, HyperDispatch} from '../../typings/hyper'; 8 | import * as uiActions from '../actions/ui'; 9 | import {getRegisteredKeys, getCommandHandler, shouldPreventDefault} from '../command-registry'; 10 | import type Terms from '../components/terms'; 11 | import {connect} from '../utils/plugins'; 12 | 13 | import {HeaderContainer} from './header'; 14 | import NotificationsContainer from './notifications'; 15 | import TermsContainer from './terms'; 16 | 17 | const isMac = /Mac/.test(navigator.userAgent); 18 | 19 | const Hyper = forwardRef((props, ref) => { 20 | const mousetrap = useRef(null); 21 | const terms = useRef(null); 22 | 23 | useEffect(() => { 24 | void attachKeyListeners(); 25 | }, [props.lastConfigUpdate]); 26 | useEffect(() => { 27 | handleFocusActive(props.activeSession); 28 | }, [props.activeSession]); 29 | 30 | const handleFocusActive = (uid?: string | null) => { 31 | const term = uid && terms.current?.getTermByUid(uid); 32 | if (term) { 33 | term.focus(); 34 | } 35 | }; 36 | 37 | const handleSelectAll = () => { 38 | const term = terms.current?.getActiveTerm(); 39 | if (term) { 40 | term.selectAll(); 41 | } 42 | }; 43 | 44 | const attachKeyListeners = async () => { 45 | if (!mousetrap.current) { 46 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 47 | mousetrap.current = new (Mousetrap as any)(window, true); 48 | mousetrap.current!.stopCallback = () => { 49 | // All events should be intercepted even if focus is in an input/textarea 50 | return false; 51 | }; 52 | } else { 53 | mousetrap.current.reset(); 54 | } 55 | 56 | const keys = await getRegisteredKeys(); 57 | Object.keys(keys).forEach((commandKeys) => { 58 | mousetrap.current?.bind( 59 | commandKeys, 60 | (e) => { 61 | const command = keys[commandKeys]; 62 | // We should tell xterm to ignore this event. 63 | (e as any).catched = true; 64 | props.execCommand(command, getCommandHandler(command), e); 65 | shouldPreventDefault(command) && e.preventDefault(); 66 | }, 67 | 'keydown' 68 | ); 69 | }); 70 | }; 71 | 72 | useEffect(() => { 73 | void attachKeyListeners(); 74 | window.rpc.on('term selectAll', handleSelectAll); 75 | }, []); 76 | 77 | const onTermsRef = (_terms: Terms | null) => { 78 | terms.current = _terms; 79 | window.focusActiveTerm = (uid?: string) => { 80 | if (uid) { 81 | handleFocusActive(uid); 82 | } else { 83 | terms.current?.getActiveTerm()?.focus(); 84 | } 85 | }; 86 | }; 87 | 88 | useEffect(() => { 89 | return () => { 90 | mousetrap.current?.reset(); 91 | }; 92 | }, []); 93 | 94 | const {isMac: isMac_, customCSS, uiFontFamily, borderColor, maximized, fullScreen} = props; 95 | const borderWidth = isMac_ ? '' : `${maximized ? '0' : '1'}px`; 96 | stylis.set({prefix: false}); 97 | return ( 98 |
    99 |
    103 | 104 | 105 | {props.customInnerChildren} 106 |
    107 | 108 | 109 | 110 | {props.customChildren} 111 | 112 | 129 | 130 | {/* 131 | Add custom CSS to Hyper. 132 | We add a scope to the customCSS so that it can get around the weighting applied by styled-jsx 133 | */} 134 |