Compare commits

..

No commits in common. "canary" and "0.4.2" have entirely different histories.

226 changed files with 4363 additions and 175341 deletions

View file

@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View file

@ -1,14 +0,0 @@
build
app/renderer
app/static
app/bin
app/dist
app/node_modules
app/typings
assets
website
bin
dist
target
cache
schema.json

View file

@ -1,161 +0,0 @@
{
"plugins": [
"react",
"prettier",
"@typescript-eslint",
"eslint-comments",
"lodash",
"import"
],
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:prettier/recommended",
"plugin:eslint-comments/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
"impliedStrict": true,
"experimentalObjectRestSpread": true
},
"allowImportExportEverywhere": true,
"project": [
"./tsconfig.eslint.json"
]
},
"env": {
"es6": true,
"browser": true,
"node": true
},
"settings": {
"react": {
"version": "detect"
},
"import/resolver": {
"typescript": {}
},
"import/internal-regex": "^(electron|react)$"
},
"rules": {
"func-names": [
"error",
"as-needed"
],
"no-shadow": "error",
"no-extra-semi": 0,
"react/prop-types": 0,
"react/react-in-jsx-scope": 0,
"react/no-unescaped-entities": 0,
"react/jsx-no-target-blank": 0,
"react/no-string-refs": 0,
"prettier/prettier": [
"error",
{
"printWidth": 120,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"bracketSpacing": false,
"semi": true,
"useTabs": false,
"bracketSameLine": false
}
],
"eslint-comments/no-unused-disable": "error",
"react/no-unknown-property":[
"error",
{
"ignore": [
"jsx",
"global"
]
}
]
},
"overrides": [
{
"files": [
"**.ts",
"**.tsx"
],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"prettier"
],
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/prefer-optional-chain": "error",
"@typescript-eslint/ban-types": "off",
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/consistent-type-imports": [ "error", { "disallowTypeAnnotations": false } ],
"lodash/prop-shorthand": [ "error", "always" ],
"lodash/import-scope": [ "error", "method" ],
"lodash/collection-return": "error",
"lodash/collection-method-value": "error",
"import/no-extraneous-dependencies": "error",
"import/no-anonymous-default-export": "error",
"import/order": [
"error",
{
"groups": [
"builtin",
"internal",
"external",
"parent",
"sibling",
"index"
],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"orderImportKind": "desc",
"caseInsensitive": true
}
}
]
}
},
{
"extends": [
"plugin:jsonc/recommended-with-json",
"plugin:json-schema-validator/recommended"
],
"files": [
"*.json"
],
"parser": "jsonc-eslint-parser",
"plugins": [
"jsonc",
"json-schema-validator"
],
"rules": {
"jsonc/array-element-newline": [
"error",
"consistent"
],
"jsonc/array-bracket-newline": [
"error",
"consistent"
],
"jsonc/indent": [
"error",
2
],
"prettier/prettier": "off",
"json-schema-validator/no-invalid": "error"
}
}
]
}

5
.gitattributes vendored
View file

@ -1,5 +0,0 @@
* text=auto
*.js text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
bin/* linguist-vendored

View file

@ -1,34 +0,0 @@
---
name: Bug report
about: Create a report to help Hyper improve
title: ''
labels: ''
assignees: ''
---
<!--
Hi there! Thank you for discovering and submitting an issue.
Before you submit this; let's make sure of a few things.
Please make sure the following boxes are ticked if they are correct.
If not, please try and fulfill these first.
-->
<!-- Checked checkbox should look like this: [x] -->
- [ ] I am on the [latest](https://github.com/vercel/hyper/releases/latest) Hyper.app version
- [ ] I have searched the [issues](https://github.com/vercel/hyper/issues) of this repo and believe that this is not a duplicate
<!--
Once those are done, if you're able to fill in the following list with your information,
it'd be very helpful to whoever handles the issue.
-->
- **OS version and name**: <!-- Replace with version + name -->
- **Hyper.app version**: <!-- Replace with version -->
- **Link of a [Gist](https://gist.github.com/) with the contents of your hyper.json**: <!-- Gist Link Here -->
- **Relevant information from devtools** _(CMD+ALT+I on macOS, CTRL+SHIFT+I elsewhere)_: <!-- Replace with info if applicable, or N/A -->
- **The issue is reproducible in vanilla Hyper.app**: <!-- Replace with info if applicable, or `Is Vanilla`. (Vanilla means Hyper.app without any add-ons or extras. Straight out of the box.) -->
## Issue
<!-- Now feel free to write your issue, but please be descriptive! Thanks again 🙌 ❤️ -->

View file

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea/feature for Hyper
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -1,25 +0,0 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
time: '11:00'
open-pull-requests-limit: 30
target-branch: canary
versioning-strategy: increase
- package-ecosystem: npm
directory: "/app"
schedule:
interval: weekly
time: '11:00'
open-pull-requests-limit: 30
target-branch: canary
versioning-strategy: increase
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
time: '11:00'
open-pull-requests-limit: 30
target-branch: canary

View file

@ -1,8 +0,0 @@
<!-- Hi there! Thanks for submitting a PR! We're excited to see what you've got for us.
- To help whoever reviews your PR, it'd be extremely helpful for you to list whether your PR is ready to be merged,
If there's anything left to do and if there are any related PRs
- It'd also be extremely helpful to enable us to update your PR incase we need to rebase or what-not by checking `Allow edits from maintainers`
- If your PR changes some API, please make a PR for hyper website too: https://github.com/vercel/hyper-site.
Thanks, again! -->

View file

@ -1,67 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ canary ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ canary ]
schedule:
- cron: '37 6 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# 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
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

View file

@ -1,63 +0,0 @@
name: Comment e2e test screenshots on PR
on:
workflow_run:
workflows: ['Node CI']
types:
- completed
jobs:
e2e_comment:
runs-on: ubuntu-latest
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Dump Workflow run info from GitHub context
env:
WORKFLOW_RUN_INFO: ${{ toJSON(github.event.workflow_run) }}
run: echo "$WORKFLOW_RUN_INFO"
- name: Download Artifacts
uses: dawidd6/action-download-artifact@v3.1.4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: nodejs.yml
run_id: ${{ github.event.workflow_run.id }}
name: e2e
- name: Get PR number
uses: dawidd6/action-download-artifact@v3.1.4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: nodejs.yml
run_id: ${{ github.event.workflow_run.id }}
name: pr_num
- name: Read the pr_num file
id: pr_num_reader
uses: juliangruber/read-file-action@v1.1.7
with:
path: ./pr_num.txt
- name: List images
run: ls -al
- name: Upload images to imgur
id: upload_screenshots
uses: devicons/public-upload-to-imgur@v2.2.2
with:
path: ./*.png
client_id: ${{ secrets.IMGUR_CLIENT_ID }}
- name: Comment on the PR
uses: jungwinter/comment@v1
env:
IMG_MARKDOWN: ${{ join(fromJSON(steps.upload_screenshots.outputs.markdown_urls), '') }}
MESSAGE: |
Hi there,
Thank you for contributing to Hyper!
You can get the build artifacts from [here](https://nightly.link/{1}/actions/runs/{2}).
Here are screenshots of Hyper built from this pr.
{0}
with:
type: create
issue_number: ${{ steps.pr_num_reader.outputs.content }}
token: ${{ secrets.GITHUB_TOKEN }}
body: ${{ format(env.MESSAGE, env.IMG_MARKDOWN, github.repository, github.event.workflow_run.id) }}
- name: Hide older comments
uses: kanga333/comment-hider@v0.4.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
leave_visible: 1
issue_number: ${{ steps.pr_num_reader.outputs.content }}

View file

@ -1,190 +0,0 @@
name: Node CI
on:
push:
branches:
- master
- canary
pull_request:
defaults:
run:
shell: bash
env:
NODE_VERSION: 18.x
jobs:
build:
runs-on: ${{matrix.os}}
strategy:
matrix:
os:
- macos-latest
- ubuntu-latest
- windows-latest
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Fix node-gyp and Python
run: python3 -m pip install packaging setuptools
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache/restore@v4
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock', 'app/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install
run: yarn install
env:
npm_config_node_gyp: ${{ github.workspace }}${{ runner.os == 'Windows' && '\node_modules\node-gyp\bin\node-gyp.js' || '/node_modules/node-gyp/bin/node-gyp.js' }}
- name: Install libarchive-tools
if: runner.os == 'Linux'
run: |
sudo apt update
sudo apt install libarchive-tools
- name: Lint and Run Unit Tests
run: yarn run test
- name: Getting Build Icon
if: github.ref == 'refs/heads/canary' || github.base_ref == 'canary'
run: |
cp build/canary.ico build/icon.ico
cp build/canary.icns build/icon.icns
- name: Build
run: |
if [ -z "$CSC_LINK" ] ; then unset CSC_LINK ; fi
if [ -z "$CSC_KEY_PASSWORD" ] ; then unset CSC_KEY_PASSWORD ; fi
if [ -z "$WIN_CSC_LINK" ] ; then unset WIN_CSC_LINK ; fi
if [ -z "$WIN_CSC_KEY_PASSWORD" ] ; then unset WIN_CSC_KEY_PASSWORD ; fi
if [ -z "$APPLE_ID" ] ; then unset APPLE_ID ; fi
if [ -z "$APPLE_APP_SPECIFIC_PASSWORD" ] ; then unset APPLE_APP_SPECIFIC_PASSWORD ; fi
yarn run dist
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERT_P12_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}
WIN_CSC_LINK: ${{ secrets.WIN_CERT_P12_BASE64 }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CERT_P12_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
- name: Archive Build Artifacts
uses: LabhanshAgrawal/upload-artifact@v3
with:
path: |
dist/*.dmg
dist/*.snap
dist/*.AppImage
dist/*.deb
dist/*.rpm
dist/*.pacman
dist/*.exe
- name: Run E2E Tests
if: runner.os != 'Linux'
run: yarn run test:e2e
- name: Run E2E Tests on Linux
if: runner.os == 'Linux'
uses: GabrielBB/xvfb-action@v1.6
with:
run: yarn run test:e2e
env:
SHELL: /bin/bash
- name: Archive E2E test screenshot
uses: actions/upload-artifact@v3
with:
name: e2e
path: dist/tmp/*.png
- name: Save the pr number in an artifact
if: github.event_name == 'pull_request'
env:
PR_NUM: ${{ github.event.number }}
run: echo $PR_NUM > pr_num.txt
- name: Upload the pr num
uses: actions/upload-artifact@v3
if: github.event_name == 'pull_request'
with:
name: pr_num
path: ./pr_num.txt
- uses: actions/cache/save@v4
if: github.event_name == 'push'
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock', 'app/yarn.lock') }}
build-linux-arm:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: armv7l
cpu: cortex-a8
image: raspios_lite:latest
- name: arm64
cpu: cortex-a53
image: raspios_lite_arm64:latest
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Fix node-gyp and Python
run: python3 -m pip install packaging setuptools
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- uses: actions/cache/restore@v4
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock', 'app/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: Install
run: |
yarn install
sudo apt update
sudo apt install libarchive-tools
- name: Compile
run: yarn run build
- name: rebuild node-pty
uses: pguyot/arm-runner-action@v2.5.2
with:
image_additional_mb: 2000
base_image: ${{ matrix.image }}
cpu: ${{ matrix.cpu }}
shell: bash
copy_artifact_path: target/node_modules/node-pty
copy_artifact_dest: target/node_modules
commands: |
wget https://nodejs.org/dist/v18.16.0/node-v18.16.0-linux-${{ matrix.name }}.tar.xz
tar -xJf node-v18.16.0-linux-${{ matrix.name }}.tar.xz
sudo cp node-v18.16.0-linux-${{ matrix.name }}/* /usr/local/ -R
npm run rebuild-node-pty
- name: chown node-pty
run: |
sudo chown -R $USER:$USER target/node_modules/node-pty
- name: Prepare v8 snapshot
if: matrix.name == 'armv7l'
run: |
sudo dpkg --add-architecture i386
sudo apt update
sudo apt install -y libglib2.0-0:i386 libexpat1:i386 libgcc-s1:i386
npm_config_arch=armv7l yarn run v8-snapshot:arch
- name: Build
run: yarn run electron-builder -l deb rpm AppImage pacman --${{ matrix.name }} -c electron-builder-linux-ci.json
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Archive Build Artifacts
uses: LabhanshAgrawal/upload-artifact@v3
with:
path: |
dist/*.snap
dist/*.AppImage
dist/*.deb
dist/*.rpm
dist/*.pacman

16
.gitignore vendored
View file

@ -1,23 +1,13 @@
# build output
dist
app/renderer
target
bin/cli.*
cache
build
# dependencies
node_modules
# logs
npm-debug.log
yarn-error.log
# optional dev config file and plugins directory
hyper.json
schema.json
plugins
# other
.next
.DS_Store
.vscode/*
!.vscode/launch.json
.idea

1
.husky/.gitignore vendored
View file

@ -1 +0,0 @@
_

View file

@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn test

22
.vscode/launch.json vendored
View file

@ -1,22 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Hyper",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
"program": "${workspaceRoot}/target/index.js",
"protocol": "inspector"
},
{
"type": "node",
"request": "launch",
"name": "cli",
"runtimeExecutable": "node",
"program": "${workspaceRoot}/bin/cli.js",
"args": ["--help"],
"protocol": "inspector"
}
]
}

View file

@ -1 +0,0 @@
registry "https://registry.npmjs.org/"

66
HISTORY.md Normal file
View file

@ -0,0 +1,66 @@
0.4.2 / 2016-07-13
==================
* fix close icon by copying static assets to build [@rauchg]
0.4.1 / 2016-07-13
==================
* noop bump to test updates
0.4.0 / 2016-07-13
==================
* implement extensible redux actions system [@rauchg]
* add support for config (~/.hyperterm.js) [@rauchg, @nfcampos]
* add support for plugins (~/.hyperterm_modules) [@rauchg]
* improve menubar look [@leo, @rauchg]
* beautiful default colors [@hharnisc, @dzannotti]
* add close icon for tabs [@johanbrook]
* add support for font size shortcuts [@jhaynie, @nfcampos]
* add support for maximizing upon double click [@jhaynie]
* add fallback fonts for linux and windows [@olliv]
* improve menu items [@rauchg, @sindresorhus]
* add proper auto updates system [@matiastucci, @rauchg]
0.3.1 / 2016-07-04
==================
* implement most recent hterm with lots of bugfixes [@dbkaplun, @rauchg]
0.3.0 / 2016-07-04
==================
* revamp the menu to be a lot more standard (#38) [@rauchg]
* move menu into its own file and refactor [@rauchg]
* add static icon, move icons to static folder [@rauchg]
* add cross-platform font family settings (#26) [@OlliV, @rauchg]
* remove `xterm.css` (no longer used, smaller build!) [@rauchg]
* change font size with command shortcuts (#34) [@jhaynie]
* implement a more reasonable update interval check frequency (#33) [@rauchg]
* add standard behavior when you double click window (#32) [@jhaynie]
0.2.1 / 2016-07-03
==================
* improvements to update checker
0.2.0 / 2016-07-03
==================
* Implement hterm (#28) [@rauchg]
* Fix "Download" link being clickable when hidden (#7) [@rauchg]
* Jump to begining of end at edges of tabs when moving sideways (#22) [@rauchg]
* Make clear where to run `npm start` (#19) [@montogeek]
* Add fullscreen menu item (#1) [@rauchg, @montogeek]
* Improve README and scripts (#15) [@leo]
* Add support for cmd+shift+[|] [@rauchg]
* Add dev build instructions to README (#3) [@rauchg]
* Make `ctrl+c` close <webview> instead of `cmd+w` (#2) [@rauchg]
* Improve `zip` builds to not include nested directory [@rauchg]
0.1.0 / 2016-07-01
==================
* initial release

21
LICENSE
View file

@ -1,21 +0,0 @@
# MIT License
Copyright (c) 2018 Vercel, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,210 +0,0 @@
# Plugin development
## Workflow
### Run Hyper in dev mode
Hyper can be run in dev mode by cloning this repository and following the ["Contributing" section of our README](https://github.com/vercel/hyper#contribute).
In dev mode you'll get more ouput and access to React/Redux dev-tools in Electron.
Prerequisites and steps are described in the ["Contributing" section of our README](https://github.com/vercel/hyper#contribute).
Be sure to use the `canary` branch.
### Create a dev config file
Copy your config file `hyper.json` to the root of your cloned repository. Hyper, in dev mode, will use this copied config file. That means that you can continue to use your main installation of Hyper with your day-to-day configuration.
After the first run, Hyper, in dev mode, will have created a new `plugins` directory in your repository directory.
### Setup your plugin
Go to your recently created `<repository_root>/plugins/local` directory and create/clone your plugin repo. An even better method on macOS/Linux is to add a symlink to your plugin directory.
Edit your dev config file, and add your plugin name (directory name in your `local` directory) in the `localPlugins` array.
```js
module.exports = {
config: {
...
},
plugins: [],
localPlugins: ['hyper-awesome-plugin'],
...
}
```
### Running your plugin
To load, your plugin should expose at least one API method. All possible methods are listed [here](https://github.com/vercel/hyper/blob/canary/app/plugins/extensions.ts).
After launching Hyper in dev mode, run `yarn run app`, it should log that your plugin has been correcty loaded: `Plugin hyper-awesome-plugin (0.1.0) loaded.`. Name and version printed are the ones in your plugins `package.json` file.
When you put a `console.log()` in your plugin code, it will be displayed in the Electron dev-tools, but only if it is located in a renderer method, like component decorators. If it is located in the Electron main process method, like the `onApp` handler, it will be displayed in your terminal where you ran `yarn run app` or in your VSCode console.
## Recipes
Almost all available API methods can be found on https://hyper.is.
If there's any missing, let us know or submit a PR to document it!
### Components
You can decorate almost all Hyper components with a Higher-Order Component (HOC). To understand their architecture, the easiest way is to use React dev-tools to dig in to their hierarchy.
Multiple plugins can decorate the same Hyper component. Thus, `Component` passed as first argument to your decorator function could possibly not be an original Hyper component but a HOC of a previous plugin. If you need to retrieve a reference to a real Hyper component, you can pass down a `onDecorated` handler.
```js
exports.decorateTerms = (Terms, {React}) => {
return class extends React.Component {
constructor(props, context) {
super(props, context);
this.terms = null;
this.onDecorated = this.onDecorated.bind(this);
}
onDecorated(terms) {
this.terms = terms;
// Don't forget to propagate it to HOC chain
if (this.props.onDecorated) this.props.onDecorated(terms);
}
render() {
return React.createElement(
Terms,
Object.assign({}, this.props, {
onDecorated: this.onDecorated
})
);
// Or if you use JSX:
// <Terms onDecorated={this.onDecorated} />
}
}
```
:warning: Note that you have to execute `this.props.onDecorated` to not break the handler chain. Without this, you could break other plugins that decorate the same component.
### Keymaps
If you want to add some keymaps, you need to do 2 things:
#### Declare your key bindings
Use the `decorateKeymaps` API handler to modify existing keymaps and add yours with the following format `command: hotkeys`.
```js
// Adding Keymaps
exports.decorateKeymaps = keymaps => {
const newKeymaps = {
'pane:maximize': 'ctrl+shift+m',
'pane:invert': 'ctrl+shift+i'
}
return Object.assign({}, keymaps, newKeymaps);
}
```
The command name can be whatever you want, but the following is better to respect the default naming convention: `<context>:<action>`.
Hotkeys are composed by [Mousetrap supported keys](https://craig.is/killing/mice#keys).
**Bonus feature**: if your command ends with `:prefix`, it would mean that you want to use this command with an additional digit to the command. Then Hyper will create all your commands under the hood. For example, this keymap `'pane:hide:prefix': 'ctrl+shift'` will automatically generate the following:
```
{
'pane:hide:1': 'ctrl+shift+1',
'pane:hide:2': 'ctrl+shift+2',
...
'pane:hide:8': 'ctrl+shift+8',
'pane:hide:last': 'ctrl+shift+9'
}
```
Notice that `9` has been replaced by `last` because most of the time this is handy if you have more than 9 items.
#### Register a handler for your commands
##### Renderer/Window
Most of time, you'll want to execute some sort of handler in context of the renderer, like dispatching a Redux action.
To trigger these handlers, you'll have to register them with the `registerCommands` Terms method.
```js
this.terms.registerCommands({
'pane:maximize': e => {
this.props.onMaximizePane();
// e parameter is React key event
e.preventDefault();
}
})
```
##### Main process
If there is no handler in the renderer for an existing command, an `rpc` message is emitted.
If you want to execute a handler in main process you have to subscribe to a message, for example:
```js
rpc.on('command pane:snapshot', () => {
/* Awesome snapshot feature */
});
```
### Menu
Your plugin can expose a `decorateMenu` function to modify the Hyper menu template.
Check the [Electron documentation](https://electronjs.org/docs/api/menu-item) for more details about the different menu item types/options available.
Be careful, a click handler will be executed on the main process. If you need to trigger a handler in the render process you need to use an `rpc` message like this:
```js
exports.decorateMenu = (menu) => {
debug('decorateMenu');
const isMac = process.platform === 'darwin';
// menu label is different on mac
const menuLabel = isMac ? 'Shell' : 'File';
return menu.map(menuCategory => {
if (menuCategory.label !== menuLabel) {
return menuItem;
}
return [
...menuCategory,
{
type: 'separator'
},
{
label: 'Clear all panes in all tabs',
accelerator: 'ctrl+shift+y',
click(item, focusedWindow) {
// on macOS, menu item can clicked without or minized window
if (focusedWindow) {
focusedWindow.rpc.emit('clear allPanes');
}
}
}
]
});
}
/* Plugin needs to register a rpc handler on renderer side for example in a Terms HOC*/
exports.decorateTerms = (Terms, { React }) => {
return class extends React.Component {
componentDidMount() {
window.rpc.on('clear allPanes',() => {
/* Awesome plugin feature */
})
}
}
}
```
### Cursor
If your plugin needs to know cursor position/size, it can decorate the Term component and pass a handler. This handler will be called with each cursor move while passing back all information about the cursor.
```js
exports.decorateTerm = (Term, { React, notify }) => {
// Define and return our higher order component.
return class extends React.Component {
onCursorMove (cursorFrame) {
// Don't forget to propagate it to HOC chain
if (this.props.onCursorMove) this.props.onCursorMove(cursorFrame);
const { x, y, width, height, col, row } = cursorFrame;
/* Awesome cursor feature */
}
}
}
```
### Require Electron
Hyper doesn't provide a reference to electron. However plugins can directly require electron.
```js
const electron = require('electron')
// or
const { dialog, Menu } = require('electron')
```
This is needed in order to allow show/hide to have proper return of focus.
## Hyper v2 breaking changes
Hyper v2 uses `xterm.js` instead of `hterm`. It means that PTY output renders now in a canvas element, not with a hackable DOM structure.
For example, plugins can't use TermCSS in order to modify text or link styles anymore. It is now required to use available configuration params that are passed down to `xterm.js`.
If your plugin was deeply linked with the `hterm` API (even public methods), it certainly doesn't work anymore.
If your plugin needs some unavailable API to tweak `xterm.js`, please open an issue. We'll be happy to expose some existing `xterm.js` API or implement new ones.

122
README.md
View file

@ -1,116 +1,28 @@
![](https://assets.vercel.com/image/upload/v1549723846/repositories/hyper/hyper-3-repo-banner.png)
<p align="center">
<a aria-label="Vercel logo" href="https://vercel.com">
<img src="https://img.shields.io/badge/MADE%20BY%20Vercel-000000.svg?style=for-the-badge&logo=vercel&labelColor=000000&logoWidth=20">
</a>
</p>
[![Node CI](https://github.com/vercel/hyper/workflows/Node%20CI/badge.svg?event=push)](https://github.com/vercel/hyper/actions?query=workflow%3A%22Node+CI%22+branch%3Acanary+event%3Apush)
[![Changelog #213](https://img.shields.io/badge/changelog-%23213-lightgrey.svg)](https://changelog.com/213)
For more details, head to: https://hyper.is
## Project goals
The goal of the project is to create a beautiful and extensible experience for command-line interface users, built on open web standards. In the beginning, our focus will be primarily around speed, stability and the development of the correct API for extension authors.
In the future, we anticipate the community will come up with innovative additions to enhance what could be the simplest, most powerful and well-tested interface for productivity.
## Usage
[Download the latest release!](https://hyper.is/#installation)
### Linux
#### Arch and derivatives
Hyper is available in the [AUR](https://aur.archlinux.org/packages/hyper/). Use an AUR [package manager](https://wiki.archlinux.org/index.php/AUR_helpers) e.g. [paru](https://github.com/Morganamilo/paru)
```sh
paru -S hyper
```
#### NixOS
Hyper is available as [Nix package](https://github.com/NixOS/nixpkgs/blob/master/pkgs/applications/misc/hyper/default.nix), to install the app run this command:
```sh
nix-env -i hyper
```
### macOS
Use [Homebrew Cask](https://brew.sh) to download the app by running these commands:
```bash
brew update
brew install --cask hyper
```
### Windows
Use [chocolatey](https://chocolatey.org/) to install the app by running the following command (package information can be found [here](https://chocolatey.org/packages/hyper/)):
```bash
choco install hyper
```
**Note:** The version available on [Homebrew Cask](https://brew.sh), [Chocolatey](https://chocolatey.org), [Snapcraft](https://snapcraft.io/store) or the [AUR](https://aur.archlinux.org) may not be the latest. Please consider downloading it from [here](https://hyper.is/#installation) if that's the case.
# hyperterm
## Contribute
Regardless of the platform you are working on, you will need to have Yarn installed. If you have never installed Yarn before, you can find out how at: https://yarnpkg.com/en/docs/install.
1. Install necessary packages:
* Windows
- Be sure to run `yarn global add windows-build-tools` from an elevated prompt (as an administrator) to install `windows-build-tools`.
* macOS
- Once you have installed Yarn, you can skip this section!
* Linux (You can see [here](https://en.wikipedia.org/wiki/List_of_Linux_distributions) what your Linux is based on.)
- RPM-based
+ `GraphicsMagick`
+ `libicns-utils`
+ `xz` (Installed by default on some distributions.)
- Debian-based
+ `graphicsmagick`
+ `icnsutils`
+ `xz-utils`
2. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device
3. Install the dependencies: `yarn`
4. Build the code and watch for changes: `yarn run dev`
5. To run `hyper`
* `yarn run app` from another terminal tab/window/pane
* If you are using **Visual Studio Code**, select `Launch Hyper` in debugger configuration to launch a new Hyper instance with debugger attached.
* If you interrupt `yarn run dev`, you'll need to relaunch it each time you want to test something. Webpack will watch changes and will rebuild renderer code when needed (and only what have changed). You'll just have to relaunch electron by using yarn run app or VSCode launch task.
To make sure that your code works in the finished application, you can generate the binaries like this:
To install `package.json` dependencies in a way where the native
modules are built with `electron`, run:
```bash
yarn run dist
$ ./scripts/install.sh
```
After that, you will see the binary in the `./dist` folder!
Then, you want to make sure `app/dist` is populated. I recommend
running `webpack` with `--watch` so that any changes you make
to the app are detected.
#### Known issues that can happen during development
```bash
$ cd app/
$ npm install
$ webpack --watch
```
##### Error building `node-pty`
Then you can run in the main directory:
If after building during development you get an alert dialog related to `node-pty` issues,
make sure its build process is working correctly by running `yarn run rebuild-node-pty`.
```bash
$ npm start
```
If you are on macOS, this typically is related to Xcode issues (like not having agreed
to the Terms of Service by running `sudo xcodebuild` after a fresh Xcode installation).
##### Error with `C++` on macOS when running `yarn`
If you are getting compiler errors when running `yarn` add the environment variable `export CXX=clang++`
##### Error with `codesign` on macOS when running `yarn run dist`
If you have issues in the `codesign` step when running `yarn run dist` on macOS, you can temporarily disable code signing locally by setting
`export CSC_IDENTITY_AUTO_DISCOVERY=false` for the current terminal session.
## Related Repositories
- [Website](https://github.com/vercel/hyper-site)
- [Sample Extension](https://github.com/vercel/hyperpower)
- [Sample Theme](https://github.com/vercel/hyperyellow)
- [Awesome Hyper](https://github.com/bnb/awesome-hyper)
...to launch the app!

View file

@ -1 +0,0 @@
registry "https://registry.npmjs.org/"

8
app/assets/icons.svg Normal file
View file

@ -0,0 +1,8 @@
<svg display="none" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<symbol id="close" viewBox="0 0 24 24">
<title>close</title>
<g><path d='M13.1919001,11.9997324 L23.7528721,22.5607045 C24.0822321,22.8904941 24.0822321,23.4241533 23.7528721,23.7526581 C23.4235121,24.0824473 22.8898521,24.0824473 22.5609201,23.7526581 L11.999948,13.1916857 L1.43897601,23.7526581 C1.109612,24.0824473 0.575952002,24.0824473 0.247020001,23.7526581 C-0.0823400003,23.4237253 -0.0823400003,22.8900657 0.247020001,22.5607045 L10.80842,11.9997324 L0.247020001,1.43961681 C-0.0823400003,1.110684 -0.0823400003,0.576168002 0.247020001,0.247663201 C0.576384002,-0.0825544003 1.11004,-0.0825544003 1.43897601,0.247663201 L11.999948,10.8082072 L22.5609201,0.247663201 C22.8902801,-0.0825544003 23.4239401,-0.0825544003 23.7528721,0.247663201 C24.0822321,0.576596002 24.0822321,1.111112 23.7528721,1.43961681 L13.1919001,11.9997324 L13.1919001,11.9997324 L13.1919001,11.9997324 Z'></path></g>
</symbol>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -1,52 +0,0 @@
import {EventEmitter} from 'events';
import fetch from 'electron-fetch';
class AutoUpdater extends EventEmitter implements Electron.AutoUpdater {
updateURL!: string;
quitAndInstall() {
this.emitError('QuitAndInstall unimplemented');
}
getFeedURL() {
return this.updateURL;
}
setFeedURL(options: Electron.FeedURLOptions) {
this.updateURL = options.url;
}
checkForUpdates() {
if (!this.updateURL) {
return this.emitError('Update URL is not set');
}
this.emit('checking-for-update');
fetch(this.updateURL)
.then((res) => {
if (res.status === 204) {
this.emit('update-not-available');
return;
}
return res.json().then(({name, notes, pub_date}: {name: string; notes: string; pub_date: string}) => {
// Only name is mandatory, needed to construct release URL.
if (!name) {
throw new Error('Malformed server response: release name is missing.');
}
const date = pub_date ? new Date(pub_date) : new Date();
this.emit('update-available', {}, notes, name, date);
});
})
.catch(this.emitError.bind(this));
}
emitError(error: string | Error) {
if (typeof error === 'string') {
error = new Error(error);
}
this.emit('error', error);
}
}
const autoUpdaterLinux = new AutoUpdater();
export default autoUpdaterLinux;

View file

@ -1,170 +0,0 @@
import {app, Menu} from 'electron';
import type {BrowserWindow} from 'electron';
import {openConfig, getConfig} from './config';
import {updatePlugins} from './plugins';
import {installCLI} from './utils/cli-install';
import * as systemContextMenu from './utils/system-context-menu';
const commands: Record<string, (focusedWindow?: BrowserWindow) => void> = {
'window:new': () => {
// If window is created on the same tick, it will consume event too
setTimeout(app.createWindow, 0);
},
'tab:new': (focusedWindow) => {
if (focusedWindow) {
focusedWindow.rpc.emit('termgroup add req', {});
} else {
setTimeout(app.createWindow, 0);
}
},
'pane:splitRight': (focusedWindow) => {
focusedWindow?.rpc.emit('split request vertical', {});
},
'pane:splitDown': (focusedWindow) => {
focusedWindow?.rpc.emit('split request horizontal', {});
},
'pane:close': (focusedWindow) => {
focusedWindow?.rpc.emit('termgroup close req');
},
'window:preferences': () => {
void openConfig();
},
'editor:clearBuffer': (focusedWindow) => {
focusedWindow?.rpc.emit('session clear req');
},
'editor:selectAll': (focusedWindow) => {
focusedWindow?.rpc.emit('term selectAll');
},
'plugins:update': () => {
updatePlugins();
},
'window:reload': (focusedWindow) => {
focusedWindow?.rpc.emit('reload');
},
'window:reloadFull': (focusedWindow) => {
focusedWindow?.reload();
},
'window:devtools': (focusedWindow) => {
if (!focusedWindow) {
return;
}
const webContents = focusedWindow.webContents;
if (webContents.isDevToolsOpened()) {
webContents.closeDevTools();
} else {
webContents.openDevTools({mode: 'detach'});
}
},
'zoom:reset': (focusedWindow) => {
focusedWindow?.rpc.emit('reset fontSize req');
},
'zoom:in': (focusedWindow) => {
focusedWindow?.rpc.emit('increase fontSize req');
},
'zoom:out': (focusedWindow) => {
focusedWindow?.rpc.emit('decrease fontSize req');
},
'tab:prev': (focusedWindow) => {
focusedWindow?.rpc.emit('move left req');
},
'tab:next': (focusedWindow) => {
focusedWindow?.rpc.emit('move right req');
},
'pane:prev': (focusedWindow) => {
focusedWindow?.rpc.emit('prev pane req');
},
'pane:next': (focusedWindow) => {
focusedWindow?.rpc.emit('next pane req');
},
'editor:movePreviousWord': (focusedWindow) => {
focusedWindow?.rpc.emit('session move word left req');
},
'editor:moveNextWord': (focusedWindow) => {
focusedWindow?.rpc.emit('session move word right req');
},
'editor:moveBeginningLine': (focusedWindow) => {
focusedWindow?.rpc.emit('session move line beginning req');
},
'editor:moveEndLine': (focusedWindow) => {
focusedWindow?.rpc.emit('session move line end req');
},
'editor:deletePreviousWord': (focusedWindow) => {
focusedWindow?.rpc.emit('session del word left req');
},
'editor:deleteNextWord': (focusedWindow) => {
focusedWindow?.rpc.emit('session del word right req');
},
'editor:deleteBeginningLine': (focusedWindow) => {
focusedWindow?.rpc.emit('session del line beginning req');
},
'editor:deleteEndLine': (focusedWindow) => {
focusedWindow?.rpc.emit('session del line end req');
},
'editor:break': (focusedWindow) => {
focusedWindow?.rpc.emit('session break req');
},
'editor:stop': (focusedWindow) => {
focusedWindow?.rpc.emit('session stop req');
},
'editor:quit': (focusedWindow) => {
focusedWindow?.rpc.emit('session quit req');
},
'editor:tmux': (focusedWindow) => {
focusedWindow?.rpc.emit('session tmux req');
},
'editor:search': (focusedWindow) => {
focusedWindow?.rpc.emit('session search');
},
'editor:search-close': (focusedWindow) => {
focusedWindow?.rpc.emit('session search close');
},
'cli:install': () => {
void installCLI(true);
},
'window:hamburgerMenu': () => {
if (process.platform !== 'darwin' && ['', true].includes(getConfig().showHamburgerMenu)) {
Menu.getApplicationMenu()!.popup({x: 25, y: 22});
}
},
'systemContextMenu:add': () => {
systemContextMenu.add();
},
'systemContextMenu:remove': () => {
systemContextMenu.remove();
},
'window:toggleKeepOnTop': (focusedWindow) => {
focusedWindow?.setAlwaysOnTop(!focusedWindow.isAlwaysOnTop());
}
};
//Special numeric command
([1, 2, 3, 4, 5, 6, 7, 8, 'last'] as const).forEach((cmdIndex) => {
const index = cmdIndex === 'last' ? cmdIndex : cmdIndex - 1;
commands[`tab:jump:${cmdIndex}`] = (focusedWindow) => {
focusedWindow?.rpc.emit('move jump req', index);
};
});
//Profile specific commands
getConfig().profiles.forEach((profile) => {
commands[`window:new:${profile.name}`] = () => {
setTimeout(() => app.createWindow(undefined, undefined, profile.name), 0);
};
commands[`tab:new:${profile.name}`] = (focusedWindow) => {
focusedWindow?.rpc.emit('termgroup add req', {profile: profile.name});
};
commands[`pane:splitRight:${profile.name}`] = (focusedWindow) => {
focusedWindow?.rpc.emit('split request vertical', {profile: profile.name});
};
commands[`pane:splitDown:${profile.name}`] = (focusedWindow) => {
focusedWindow?.rpc.emit('split request horizontal', {profile: profile.name});
};
});
export const execCommand = (command: string, focusedWindow?: BrowserWindow) => {
const fn = commands[command];
if (fn) {
fn(focusedWindow);
}
};

View file

@ -1,156 +0,0 @@
import {app} from 'electron';
import chokidar from 'chokidar';
import type {parsedConfig, configOptions} from '../typings/config';
import {_import, getDefaultConfig} from './config/import';
import _openConfig from './config/open';
import {cfgPath, cfgDir} from './config/paths';
import notify from './notify';
import {getColorMap} from './utils/colors';
const watchers: Function[] = [];
let cfg: parsedConfig = {} as any;
let _watcher: chokidar.FSWatcher;
export const getDeprecatedCSS = (config: configOptions) => {
const deprecated: string[] = [];
const deprecatedCSS = ['x-screen', 'x-row', 'cursor-node', '::selection'];
deprecatedCSS.forEach((css) => {
if (config.css?.includes(css) || config.termCSS?.includes(css)) {
deprecated.push(css);
}
});
return deprecated;
};
const checkDeprecatedConfig = () => {
if (!cfg.config) {
return;
}
const deprecated = getDeprecatedCSS(cfg.config);
if (deprecated.length === 0) {
return;
}
const deprecatedStr = deprecated.join(', ');
notify('Configuration warning', `Your configuration uses some deprecated CSS classes (${deprecatedStr})`);
};
const _watch = () => {
if (_watcher) {
return;
}
const onChange = () => {
// Need to wait 100ms to ensure that write is complete
setTimeout(() => {
cfg = _import();
notify('Configuration updated', 'Hyper configuration reloaded!');
watchers.forEach((fn) => {
fn();
});
checkDeprecatedConfig();
}, 100);
};
_watcher = chokidar.watch(cfgPath);
_watcher.on('change', onChange);
_watcher.on('error', (error) => {
console.error('error watching config', error);
});
app.on('before-quit', () => {
if (Object.keys(_watcher.getWatched()).length > 0) {
_watcher.close().catch((err) => {
console.warn(err);
});
}
});
};
export const subscribe = (fn: Function) => {
watchers.push(fn);
return () => {
watchers.splice(watchers.indexOf(fn), 1);
};
};
export const getConfigDir = () => {
// expose config directory to load plugin from the right place
return cfgDir;
};
export const getDefaultProfile = () => {
return cfg.config.defaultProfile || cfg.config.profiles[0]?.name || 'default';
};
// get config for the default profile, keeping it for backward compatibility
export const getConfig = () => {
return getProfileConfig(getDefaultProfile());
};
export const getProfiles = () => {
return cfg.config.profiles;
};
export const getProfileConfig = (profileName: string): configOptions => {
const {profiles, defaultProfile, ...baseConfig} = cfg.config;
const profileConfig = profiles.find((p) => p.name === profileName)?.config || {};
for (const key in profileConfig) {
if (typeof baseConfig[key] === 'object' && !Array.isArray(baseConfig[key])) {
baseConfig[key] = {...baseConfig[key], ...profileConfig[key]};
} else {
baseConfig[key] = profileConfig[key];
}
}
return {...baseConfig, defaultProfile, profiles};
};
export const openConfig = () => {
return _openConfig();
};
export const getPlugins = (): {plugins: string[]; localPlugins: string[]} => {
return {
plugins: cfg.plugins,
localPlugins: cfg.localPlugins
};
};
export const getKeymaps = () => {
return cfg.keymaps;
};
export const setup = () => {
cfg = _import();
_watch();
checkDeprecatedConfig();
};
export {get as getWin, recordState as winRecord, defaults as windowDefaults} from './config/windows';
export const fixConfigDefaults = (decoratedConfig: configOptions) => {
const defaultConfig = getDefaultConfig().config!;
decoratedConfig.colors = getColorMap(decoratedConfig.colors) || {};
// We must have default colors for xterm css.
decoratedConfig.colors = {...defaultConfig.colors, ...decoratedConfig.colors};
return decoratedConfig;
};
export const htermConfigTranslate = (config: configOptions) => {
const cssReplacements: Record<string, string> = {
'x-screen x-row([ {.[])': '.xterm-rows > div$1',
'.cursor-node([ {.[])': '.terminal-cursor$1',
'::selection([ {.[])': '.terminal .xterm-selection div$1',
'x-screen a([ {.[])': '.terminal a$1',
'x-row a([ {.[])': '.terminal a$1'
};
Object.keys(cssReplacements).forEach((pattern) => {
const searchvalue = new RegExp(pattern, 'g');
const newvalue = cssReplacements[pattern];
config.css = config.css?.replace(searchvalue, newvalue);
config.termCSS = config.termCSS?.replace(searchvalue, newvalue);
});
return config;
};

View file

@ -1,77 +0,0 @@
{
"$schema": "./schema.json",
"config": {
"updateChannel": "stable",
"fontSize": 12,
"fontFamily": "Menlo, \"DejaVu Sans Mono\", Consolas, \"Lucida Console\", monospace",
"fontWeight": "normal",
"fontWeightBold": "bold",
"lineHeight": 1,
"letterSpacing": 0,
"scrollback": 1000,
"cursorColor": "rgba(248,28,229,0.8)",
"cursorAccentColor": "#000",
"cursorShape": "BLOCK",
"cursorBlink": false,
"foregroundColor": "#fff",
"backgroundColor": "#000",
"selectionColor": "rgba(248,28,229,0.3)",
"borderColor": "#333",
"css": "",
"termCSS": "",
"workingDirectory": "",
"showHamburgerMenu": "",
"showWindowControls": "",
"padding": "12px 14px",
"colors": {
"black": "#000000",
"red": "#C51E14",
"green": "#1DC121",
"yellow": "#C7C329",
"blue": "#0A2FC4",
"magenta": "#C839C5",
"cyan": "#20C5C6",
"white": "#C7C7C7",
"lightBlack": "#686868",
"lightRed": "#FD6F6B",
"lightGreen": "#67F86F",
"lightYellow": "#FFFA72",
"lightBlue": "#6A76FB",
"lightMagenta": "#FD7CFC",
"lightCyan": "#68FDFE",
"lightWhite": "#FFFFFF",
"limeGreen": "#32CD32",
"lightCoral": "#F08080"
},
"shell": "",
"shellArgs": [
"--login"
],
"env": {},
"bell": "SOUND",
"bellSound": null,
"bellSoundURL": null,
"copyOnSelect": false,
"defaultSSHApp": true,
"quickEdit": false,
"macOptionSelectionMode": "vertical",
"webGLRenderer": false,
"webLinksActivationKey": "",
"disableLigatures": true,
"disableAutoUpdates": false,
"autoUpdatePlugins": true,
"preserveCWD": true,
"screenReaderMode": false,
"imageSupport": true,
"defaultProfile": "default",
"profiles": [
{
"name": "default",
"config": {}
}
]
},
"plugins": [],
"localPlugins": [],
"keymaps": {}
}

View file

@ -1,65 +0,0 @@
import {readFileSync, mkdirpSync} from 'fs-extra';
import type {rawConfig} from '../../typings/config';
import notify from '../notify';
import {_init} from './init';
import {migrateHyper3Config} from './migrate';
import {defaultCfg, cfgPath, plugs, defaultPlatformKeyPath} from './paths';
let defaultConfig: rawConfig;
const _importConf = () => {
// init plugin directories if not present
mkdirpSync(plugs.base);
mkdirpSync(plugs.local);
try {
migrateHyper3Config();
} catch (err) {
console.error(err);
}
let defaultCfgRaw = '{}';
try {
defaultCfgRaw = readFileSync(defaultCfg, 'utf8');
} catch (err) {
console.log(err);
}
const _defaultCfg = JSON.parse(defaultCfgRaw) as rawConfig;
// Importing platform specific keymap
let content = '{}';
try {
content = readFileSync(defaultPlatformKeyPath(), 'utf8');
} catch (err) {
console.error(err);
}
const mapping = JSON.parse(content) as Record<string, string | string[]>;
_defaultCfg.keymaps = mapping;
// Import user config
let userCfg: rawConfig;
try {
userCfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
} catch (err) {
notify("Couldn't parse config file. Using default config instead.");
userCfg = JSON.parse(defaultCfgRaw);
}
return {userCfg, defaultCfg: _defaultCfg};
};
export const _import = () => {
const imported = _importConf();
defaultConfig = imported.defaultCfg;
const result = _init(imported.userCfg, imported.defaultCfg);
return result;
};
export const getDefaultConfig = () => {
if (!defaultConfig) {
defaultConfig = _importConf().defaultCfg;
}
return defaultConfig;
};

View file

@ -1,63 +0,0 @@
import vm from 'vm';
import merge from 'lodash/merge';
import type {parsedConfig, rawConfig, configOptions} from '../../typings/config';
import notify from '../notify';
import mapKeys from '../utils/map-keys';
const _extract = (script?: vm.Script): Record<string, any> => {
const module: Record<string, any> = {};
script?.runInNewContext({module}, {displayErrors: true});
if (!module.exports) {
throw new Error('Error reading configuration: `module.exports` not set');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return module.exports;
};
const _syntaxValidation = (cfg: string) => {
try {
return new vm.Script(cfg, {filename: '.hyper.js'});
} catch (_err) {
const err = _err as {name: string};
notify(`Error loading config: ${err.name}`, JSON.stringify(err), {error: err});
}
};
const _extractDefault = (cfg: string) => {
return _extract(_syntaxValidation(cfg));
};
// init config
const _init = (userCfg: rawConfig, defaultCfg: rawConfig): parsedConfig => {
return {
config: (() => {
if (userCfg?.config) {
const conf = userCfg.config;
conf.defaultProfile = conf.defaultProfile || 'default';
conf.profiles = conf.profiles || [];
conf.profiles = conf.profiles.length > 0 ? conf.profiles : [{name: 'default', config: {}}];
conf.profiles = conf.profiles.map((p, i) => ({
...p,
name: p.name || `profile-${i + 1}`,
config: p.config || {}
}));
if (!conf.profiles.map((p) => p.name).includes(conf.defaultProfile)) {
conf.defaultProfile = conf.profiles[0].name;
}
return merge({}, defaultCfg.config, conf);
} else {
notify('Error reading configuration: `config` key is missing');
return defaultCfg.config || ({} as configOptions);
}
})(),
// Merging platform specific keymaps with user defined keymaps
keymaps: mapKeys({...defaultCfg.keymaps, ...userCfg?.keymaps}),
// Ignore undefined values in plugin and localPlugins array Issue #1862
plugins: userCfg?.plugins?.filter(Boolean) || [],
localPlugins: userCfg?.localPlugins?.filter(Boolean) || []
};
};
export {_init, _extractDefault};

View file

@ -1,190 +0,0 @@
import {dirname, resolve} from 'path';
import {builders, namedTypes} from 'ast-types';
import type {ExpressionKind} from 'ast-types/lib/gen/kinds';
import {copy, copySync, existsSync, readFileSync, writeFileSync} from 'fs-extra';
import merge from 'lodash/merge';
import {parse, prettyPrint} from 'recast';
import * as babelParser from 'recast/parsers/babel';
import notify from '../notify';
import {_extractDefault} from './init';
import {cfgDir, cfgPath, defaultCfg, legacyCfgPath, plugs, schemaFile, schemaPath} from './paths';
// function to remove all json serializable entries from an array expression
function removeElements(node: namedTypes.ArrayExpression): namedTypes.ArrayExpression {
const newElements = node.elements.filter((element) => {
if (namedTypes.ObjectExpression.check(element)) {
const newElement = removeProperties(element);
if (newElement.properties.length === 0) {
return false;
}
} else if (namedTypes.ArrayExpression.check(element)) {
const newElement = removeElements(element);
if (newElement.elements.length === 0) {
return false;
}
} else if (namedTypes.Literal.check(element)) {
return false;
}
return true;
});
return {...node, elements: newElements};
}
// function to remove all json serializable properties from an object expression
function removeProperties(node: namedTypes.ObjectExpression): namedTypes.ObjectExpression {
const newProperties = node.properties.filter((property) => {
if (
namedTypes.ObjectProperty.check(property) &&
(namedTypes.Literal.check(property.key) || namedTypes.Identifier.check(property.key)) &&
!property.computed
) {
if (namedTypes.ObjectExpression.check(property.value)) {
const newValue = removeProperties(property.value);
if (newValue.properties.length === 0) {
return false;
}
} else if (namedTypes.ArrayExpression.check(property.value)) {
const newValue = removeElements(property.value);
if (newValue.elements.length === 0) {
return false;
}
} else if (namedTypes.Literal.check(property.value)) {
return false;
}
}
return true;
});
return {...node, properties: newProperties};
}
export function configToPlugin(code: string): string {
const ast: namedTypes.File = parse(code, {
parser: babelParser
});
const statements = ast.program.body;
let moduleExportsNode: namedTypes.AssignmentExpression | null = null;
let configNode: ExpressionKind | null = null;
for (const statement of statements) {
if (namedTypes.ExpressionStatement.check(statement)) {
const expression = statement.expression;
if (
namedTypes.AssignmentExpression.check(expression) &&
expression.operator === '=' &&
namedTypes.MemberExpression.check(expression.left) &&
namedTypes.Identifier.check(expression.left.object) &&
expression.left.object.name === 'module' &&
namedTypes.Identifier.check(expression.left.property) &&
expression.left.property.name === 'exports'
) {
moduleExportsNode = expression;
if (namedTypes.ObjectExpression.check(expression.right)) {
const properties = expression.right.properties;
for (const property of properties) {
if (
namedTypes.ObjectProperty.check(property) &&
namedTypes.Identifier.check(property.key) &&
property.key.name === 'config'
) {
configNode = property.value as ExpressionKind;
if (namedTypes.ObjectExpression.check(property.value)) {
configNode = removeProperties(property.value);
}
}
}
} else {
configNode = builders.memberExpression(moduleExportsNode.right, builders.identifier('config'));
}
}
}
}
if (!moduleExportsNode) {
console.log('No module.exports found in config');
return '';
}
if (!configNode) {
console.log('No config field found in module.exports');
return '';
}
if (namedTypes.ObjectExpression.check(configNode) && configNode.properties.length === 0) {
return '';
}
moduleExportsNode.right = builders.objectExpression([
builders.property(
'init',
builders.identifier('decorateConfig'),
builders.arrowFunctionExpression(
[builders.identifier('_config')],
builders.callExpression(
builders.memberExpression(builders.identifier('Object'), builders.identifier('assign')),
[builders.objectExpression([]), builders.identifier('_config'), configNode]
)
)
)
]);
return prettyPrint(ast, {tabWidth: 2}).code;
}
export const _write = (path: string, data: string) => {
// This method will take text formatted as Unix line endings and transform it
// to text formatted with DOS line endings. We do this because the default
// text editor on Windows (notepad) doesn't Deal with LF files. Still. In 2017.
const crlfify = (str: string) => {
return str.replace(/\r?\n/g, '\r\n');
};
const format = process.platform === 'win32' ? crlfify(data.toString()) : data;
writeFileSync(path, format, 'utf8');
};
// Migrate Hyper3 config to Hyper4 but only if the user hasn't manually
// touched the new config and if the old config is not a symlink
export const migrateHyper3Config = () => {
copy(schemaPath, resolve(cfgDir, schemaFile), (err) => {
if (err) {
console.error(err);
}
});
if (existsSync(cfgPath)) {
return;
}
if (!existsSync(legacyCfgPath)) {
copySync(defaultCfg, cfgPath);
return;
}
// Migrate
copySync(resolve(dirname(legacyCfgPath), '.hyper_plugins', 'local'), plugs.local);
const defaultCfgData = JSON.parse(readFileSync(defaultCfg, 'utf8'));
let newCfgData;
try {
const legacyCfgRaw = readFileSync(legacyCfgPath, 'utf8');
const legacyCfgData = _extractDefault(legacyCfgRaw);
newCfgData = merge({}, defaultCfgData, legacyCfgData);
const pluginCode = configToPlugin(legacyCfgRaw);
if (pluginCode) {
const pluginPath = resolve(plugs.local, 'migrated-hyper3-config.js');
newCfgData.localPlugins = ['migrated-hyper3-config', ...(newCfgData.localPlugins || [])];
_write(pluginPath, pluginCode);
}
} catch (e) {
console.error(e);
notify(
'Hyper 4',
`Failed to migrate your config from Hyper 3.\nDefault config will be created instead at ${cfgPath}`
);
newCfgData = defaultCfgData;
}
_write(cfgPath, JSON.stringify(newCfgData, null, 2));
notify('Hyper 4', `Settings location and format has changed to ${cfgPath}`);
};

View file

@ -1,80 +0,0 @@
import {exec} from 'child_process';
import {shell} from 'electron';
import * as Registry from 'native-reg';
import {cfgPath} from './paths';
const getUserChoiceKey = () => {
try {
// Load FileExts keys for .js files
const fileExtsKeys = Registry.openKey(
Registry.HKCU,
'Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\.js',
Registry.Access.READ
);
const keys = fileExtsKeys ? Registry.enumKeyNames(fileExtsKeys) : [];
Registry.closeKey(fileExtsKeys);
// Find UserChoice key
const userChoice = keys.find((k) => k.endsWith('UserChoice'));
return userChoice
? `Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\.js\\${userChoice}`
: userChoice;
} catch (error) {
console.error(error);
return;
}
};
const hasDefaultSet = () => {
const userChoice = getUserChoiceKey();
if (!userChoice) return false;
try {
// Load key values
const userChoiceKey = Registry.openKey(Registry.HKCU, userChoice, Registry.Access.READ)!;
const values: string[] = Registry.enumValueNames(userChoiceKey).map(
(x) => (Registry.queryValue(userChoiceKey, x) as string) || ''
);
Registry.closeKey(userChoiceKey);
// Look for default program
const hasDefaultProgramConfigured = values.every(
(value) => value && typeof value === 'string' && !value.includes('WScript.exe') && !value.includes('JSFile')
);
return hasDefaultProgramConfigured;
} catch (error) {
console.error(error);
return false;
}
};
// This mimics shell.openItem, true if it worked, false if not.
const openNotepad = (file: string) =>
new Promise<boolean>((resolve) => {
exec(`start notepad.exe ${file}`, (error) => {
resolve(!error);
});
});
const openConfig = () => {
// Windows opens .js files with WScript.exe by default
// If the user hasn't set up an editor for .js files, we fallback to notepad.
if (process.platform === 'win32') {
try {
if (hasDefaultSet()) {
return shell.openPath(cfgPath).then((error) => error === '');
}
console.warn('No default app set for .js files, using notepad.exe fallback');
} catch (err) {
console.error('Open config with default app error:', err);
}
return openNotepad(cfgPath);
}
return shell.openPath(cfgPath).then((error) => error === '');
};
export default openConfig;

View file

@ -1,96 +0,0 @@
// This module exports paths, names, and other metadata that is referenced
import {statSync} from 'fs';
import {homedir} from 'os';
import {resolve, join} from 'path';
import {app} from 'electron';
import isDev from 'electron-is-dev';
const cfgFile = 'hyper.json';
const defaultCfgFile = 'config-default.json';
const schemaFile = 'schema.json';
const homeDirectory = homedir();
// If the user defines XDG_CONFIG_HOME they definitely want their config there,
// otherwise use the home directory in linux/mac and userdata in windows
let cfgDir = process.env.XDG_CONFIG_HOME
? join(process.env.XDG_CONFIG_HOME, 'Hyper')
: process.platform === 'win32'
? app.getPath('userData')
: join(homeDirectory, '.config', 'Hyper');
const legacyCfgPath = join(
process.env.XDG_CONFIG_HOME !== undefined
? join(process.env.XDG_CONFIG_HOME, 'hyper')
: process.platform == 'win32'
? app.getPath('userData')
: homedir(),
'.hyper.js'
);
let cfgPath = join(cfgDir, cfgFile);
const schemaPath = resolve(__dirname, schemaFile);
const devDir = resolve(__dirname, '../..');
const devCfg = join(devDir, cfgFile);
const defaultCfg = resolve(__dirname, defaultCfgFile);
if (isDev) {
// if a local config file exists, use it
try {
statSync(devCfg);
cfgPath = devCfg;
cfgDir = devDir;
console.log('using config file:', cfgPath);
} catch (err) {
// ignore
}
}
const plugins = resolve(cfgDir, 'plugins');
const plugs = {
base: plugins,
local: resolve(plugins, 'local'),
cache: resolve(plugins, 'cache')
};
const yarn = resolve(__dirname, '../../bin/yarn-standalone.js');
const cliScriptPath = resolve(__dirname, '../../bin/hyper');
const cliLinkPath = '/usr/local/bin/hyper';
const icon = resolve(__dirname, '../static/icon96x96.png');
const keymapPath = resolve(__dirname, '../keymaps');
const darwinKeys = join(keymapPath, 'darwin.json');
const win32Keys = join(keymapPath, 'win32.json');
const linuxKeys = join(keymapPath, 'linux.json');
const defaultPlatformKeyPath = () => {
switch (process.platform) {
case 'darwin':
return darwinKeys;
case 'win32':
return win32Keys;
case 'linux':
return linuxKeys;
default:
return darwinKeys;
}
};
export {
cfgDir,
cfgPath,
legacyCfgPath,
cfgFile,
defaultCfg,
icon,
defaultPlatformKeyPath,
plugs,
yarn,
cliScriptPath,
cliLinkPath,
homeDirectory,
schemaFile,
schemaPath
};

View file

@ -1,756 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"FontWeight": {
"anyOf": [
{
"enum": [
"100",
"200",
"300",
"400",
"500",
"600",
"700",
"800",
"900",
"bold",
"normal"
],
"type": "string"
},
{
"type": "number"
}
],
"description": "A string or number representing text font weight."
},
"Partial<profileConfigOptions>": {
"properties": {
"backgroundColor": {
"description": "terminal background color\n\nopacity is only supported on macOS",
"type": "string"
},
"bell": {
"description": "Supported Options:\n1. 'SOUND' -> Enables the bell as a sound\n2. false: turns off the bell",
"enum": [
"SOUND",
false
]
},
"bellSound": {
"description": "base64 encoded string of the sound file to use for the bell\nif null, the default bell will be used",
"type": [
"string",
"null"
]
},
"bellSoundURL": {
"description": "An absolute file path to a sound file on the machine.",
"type": [
"string",
"null"
]
},
"borderColor": {
"description": "border color (window, tabs)",
"type": "string"
},
"colors": {
"description": "the full list. if you're going to provide the full color palette,\nincluding the 6 x 6 color cubes and the grayscale map, just provide\nan array here instead of a color map object",
"properties": {
"black": {
"type": "string"
},
"blue": {
"type": "string"
},
"cyan": {
"type": "string"
},
"green": {
"type": "string"
},
"lightBlack": {
"type": "string"
},
"lightBlue": {
"type": "string"
},
"lightCyan": {
"type": "string"
},
"lightGreen": {
"type": "string"
},
"lightMagenta": {
"type": "string"
},
"lightRed": {
"type": "string"
},
"lightWhite": {
"type": "string"
},
"lightYellow": {
"type": "string"
},
"magenta": {
"type": "string"
},
"red": {
"type": "string"
},
"white": {
"type": "string"
},
"yellow": {
"type": "string"
}
},
"required": [
"black",
"blue",
"cyan",
"green",
"lightBlack",
"lightBlue",
"lightCyan",
"lightGreen",
"lightMagenta",
"lightRed",
"lightWhite",
"lightYellow",
"magenta",
"red",
"white",
"yellow"
],
"type": "object"
},
"copyOnSelect": {
"description": "if `true` selected text will automatically be copied to the clipboard",
"type": "boolean"
},
"css": {
"description": "custom CSS to embed in the main window",
"type": "string"
},
"cursorAccentColor": {
"description": "terminal text color under BLOCK cursor",
"type": "string"
},
"cursorBlink": {
"description": "set to `true` for blinking cursor",
"type": "boolean"
},
"cursorColor": {
"description": "terminal cursor background color and opacity (hex, rgb, hsl, hsv, hwb or cmyk)",
"type": "string"
},
"cursorShape": {
"description": "`'BEAM'` for |, `'UNDERLINE'` for _, `'BLOCK'` for █",
"enum": [
"BEAM",
"BLOCK",
"UNDERLINE"
],
"type": "string"
},
"disableLigatures": {
"description": "if `false` Hyper will use ligatures provided by some fonts",
"type": "boolean"
},
"env": {
"additionalProperties": {
"type": "string"
},
"description": "for environment variables",
"type": "object"
},
"fontFamily": {
"description": "font family with optional fallbacks",
"type": "string"
},
"fontSize": {
"description": "default font size in pixels for all tabs",
"type": "number"
},
"fontWeight": {
"$ref": "#/definitions/FontWeight",
"description": "default font weight eg:'normal', '400', 'bold'"
},
"fontWeightBold": {
"$ref": "#/definitions/FontWeight",
"description": "font weight for bold characters eg:'normal', '600', 'bold'"
},
"foregroundColor": {
"description": "color of the text",
"type": "string"
},
"imageSupport": {
"description": "Whether to enable Sixel and iTerm2 inline image protocol support or not.",
"type": "boolean"
},
"letterSpacing": {
"description": "letter spacing as a relative unit",
"type": "number"
},
"lineHeight": {
"description": "line height as a relative unit",
"type": "number"
},
"macOptionSelectionMode": {
"description": "choose either `'vertical'`, if you want the column mode when Option key is hold during selection (Default)\nor `'force'`, if you want to force selection regardless of whether the terminal is in mouse events mode\n(inside tmux or vim with mouse mode enabled for example).",
"type": "string"
},
"modifierKeys": {
"properties": {
"altIsMeta": {
"type": "boolean"
},
"cmdIsMeta": {
"type": "boolean"
}
},
"required": [
"altIsMeta",
"cmdIsMeta"
],
"type": "object"
},
"padding": {
"description": "custom padding (CSS format, i.e.: `top right bottom left` or `top horizontal bottom` or `vertical horizontal` or `all`)",
"type": "string"
},
"preserveCWD": {
"description": "set to true to preserve working directory when creating splits or tabs",
"type": "boolean"
},
"quickEdit": {
"description": "if `true` on right click selected text will be copied or pasted if no\nselection is present (`true` by default on Windows and disables the context menu feature)",
"type": "boolean"
},
"screenReaderMode": {
"description": "set to true to enable screen reading apps (like NVDA) to read the contents of the terminal",
"type": "boolean"
},
"scrollback": {
"type": "number"
},
"selectionColor": {
"description": "terminal selection color",
"type": "string"
},
"shell": {
"description": "the shell to run when spawning a new session (e.g. /usr/local/bin/fish)\nif left empty, your system's login shell will be used by default\n\nWindows\n- Make sure to use a full path if the binary name doesn't work\n- Remove `--login` in shellArgs\n\nWindows Subsystem for Linux (WSL) - previously Bash on Windows\n- Example: `C:\\\\Windows\\\\System32\\\\wsl.exe`\n\nGit-bash on Windows\n- Example: `C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe`\n\nPowerShell on Windows\n- Example: `C:\\\\WINDOWS\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe`\n\nCygwin\n- Example: `C:\\\\cygwin64\\\\bin\\\\bash.exe`\n\nGit Bash\n- Example: `C:\\\\Program Files\\\\Git\\\\git-cmd.exe`\nThen Add `--command=usr/bin/bash.exe` to shellArgs",
"type": "string"
},
"shellArgs": {
"description": "for setting shell arguments (e.g. for using interactive shellArgs: `['-i']`)\nby default `['--login']` will be used",
"items": {
"type": "string"
},
"type": "array"
},
"showHamburgerMenu": {
"description": "if you're using a Linux setup which show native menus, set to false\n\ndefault: `true` on Linux, `true` on Windows, ignored on macOS",
"enum": [
"",
false,
true
]
},
"showWindowControls": {
"description": "set to `false` if you want to hide the minimize, maximize and close buttons\n\nadditionally, set to `'left'` if you want them on the left, like in Ubuntu\n\ndefault: `true` on Windows and Linux, ignored on macOS",
"enum": [
"",
false,
"left",
true
]
},
"termCSS": {
"description": "custom CSS to embed in the terminal window",
"type": "string"
},
"uiFontFamily": {
"type": "string"
},
"webGLRenderer": {
"description": "Whether to use the WebGL renderer. Set it to false to use canvas-based\nrendering (slower, but supports transparent backgrounds)",
"type": "boolean"
},
"webLinksActivationKey": {
"description": "keypress required for weblink activation: [ctrl | alt | meta | shift]",
"enum": [
"",
"alt",
"ctrl",
"meta",
"shift"
],
"type": "string"
},
"windowSize": {
"description": "Initial window size in pixels",
"items": [
{
"type": "number"
},
{
"type": "number"
}
],
"maxItems": 2,
"minItems": 2,
"type": "array"
},
"workingDirectory": {
"description": "set custom startup directory (must be an absolute path)",
"type": "string"
}
},
"type": "object"
},
"configOptions": {
"allOf": [
{
"properties": {
"autoUpdatePlugins": {
"description": "if `true` (default), Hyper will update plugins every 5 hours\nyou can also set it to a custom time e.g. `1d` or `2h`",
"type": [
"string",
"boolean"
]
},
"defaultSSHApp": {
"description": "if `true` hyper will be set as the default protocol client for SSH",
"type": "boolean"
},
"disableAutoUpdates": {
"description": "if `true` hyper will not check for updates",
"type": "boolean"
},
"updateChannel": {
"description": "choose either `'stable'` for receiving highly polished, or `'canary'` for less polished but more frequent updates",
"enum": [
"canary",
"stable"
],
"type": "string"
},
"useConpty": {
"type": "boolean"
}
},
"required": [
"autoUpdatePlugins",
"defaultSSHApp",
"disableAutoUpdates",
"updateChannel"
],
"type": "object"
},
{
"properties": {
"backgroundColor": {
"description": "terminal background color\n\nopacity is only supported on macOS",
"type": "string"
},
"bell": {
"description": "Supported Options:\n1. 'SOUND' -> Enables the bell as a sound\n2. false: turns off the bell",
"enum": [
"SOUND",
false
]
},
"bellSound": {
"description": "base64 encoded string of the sound file to use for the bell\nif null, the default bell will be used",
"type": [
"string",
"null"
]
},
"bellSoundURL": {
"description": "An absolute file path to a sound file on the machine.",
"type": [
"string",
"null"
]
},
"borderColor": {
"description": "border color (window, tabs)",
"type": "string"
},
"colors": {
"description": "the full list. if you're going to provide the full color palette,\nincluding the 6 x 6 color cubes and the grayscale map, just provide\nan array here instead of a color map object",
"properties": {
"black": {
"type": "string"
},
"blue": {
"type": "string"
},
"cyan": {
"type": "string"
},
"green": {
"type": "string"
},
"lightBlack": {
"type": "string"
},
"lightBlue": {
"type": "string"
},
"lightCyan": {
"type": "string"
},
"lightGreen": {
"type": "string"
},
"lightMagenta": {
"type": "string"
},
"lightRed": {
"type": "string"
},
"lightWhite": {
"type": "string"
},
"lightYellow": {
"type": "string"
},
"magenta": {
"type": "string"
},
"red": {
"type": "string"
},
"white": {
"type": "string"
},
"yellow": {
"type": "string"
}
},
"required": [
"black",
"blue",
"cyan",
"green",
"lightBlack",
"lightBlue",
"lightCyan",
"lightGreen",
"lightMagenta",
"lightRed",
"lightWhite",
"lightYellow",
"magenta",
"red",
"white",
"yellow"
],
"type": "object"
},
"copyOnSelect": {
"description": "if `true` selected text will automatically be copied to the clipboard",
"type": "boolean"
},
"css": {
"description": "custom CSS to embed in the main window",
"type": "string"
},
"cursorAccentColor": {
"description": "terminal text color under BLOCK cursor",
"type": "string"
},
"cursorBlink": {
"description": "set to `true` for blinking cursor",
"type": "boolean"
},
"cursorColor": {
"description": "terminal cursor background color and opacity (hex, rgb, hsl, hsv, hwb or cmyk)",
"type": "string"
},
"cursorShape": {
"description": "`'BEAM'` for |, `'UNDERLINE'` for _, `'BLOCK'` for █",
"enum": [
"BEAM",
"BLOCK",
"UNDERLINE"
],
"type": "string"
},
"disableLigatures": {
"description": "if `false` Hyper will use ligatures provided by some fonts",
"type": "boolean"
},
"env": {
"additionalProperties": {
"type": "string"
},
"description": "for environment variables",
"type": "object"
},
"fontFamily": {
"description": "font family with optional fallbacks",
"type": "string"
},
"fontSize": {
"description": "default font size in pixels for all tabs",
"type": "number"
},
"fontWeight": {
"$ref": "#/definitions/FontWeight",
"description": "default font weight eg:'normal', '400', 'bold'"
},
"fontWeightBold": {
"$ref": "#/definitions/FontWeight",
"description": "font weight for bold characters eg:'normal', '600', 'bold'"
},
"foregroundColor": {
"description": "color of the text",
"type": "string"
},
"imageSupport": {
"description": "Whether to enable Sixel and iTerm2 inline image protocol support or not.",
"type": "boolean"
},
"letterSpacing": {
"description": "letter spacing as a relative unit",
"type": "number"
},
"lineHeight": {
"description": "line height as a relative unit",
"type": "number"
},
"macOptionSelectionMode": {
"description": "choose either `'vertical'`, if you want the column mode when Option key is hold during selection (Default)\nor `'force'`, if you want to force selection regardless of whether the terminal is in mouse events mode\n(inside tmux or vim with mouse mode enabled for example).",
"type": "string"
},
"modifierKeys": {
"properties": {
"altIsMeta": {
"type": "boolean"
},
"cmdIsMeta": {
"type": "boolean"
}
},
"required": [
"altIsMeta",
"cmdIsMeta"
],
"type": "object"
},
"padding": {
"description": "custom padding (CSS format, i.e.: `top right bottom left` or `top horizontal bottom` or `vertical horizontal` or `all`)",
"type": "string"
},
"preserveCWD": {
"description": "set to true to preserve working directory when creating splits or tabs",
"type": "boolean"
},
"quickEdit": {
"description": "if `true` on right click selected text will be copied or pasted if no\nselection is present (`true` by default on Windows and disables the context menu feature)",
"type": "boolean"
},
"screenReaderMode": {
"description": "set to true to enable screen reading apps (like NVDA) to read the contents of the terminal",
"type": "boolean"
},
"scrollback": {
"type": "number"
},
"selectionColor": {
"description": "terminal selection color",
"type": "string"
},
"shell": {
"description": "the shell to run when spawning a new session (e.g. /usr/local/bin/fish)\nif left empty, your system's login shell will be used by default\n\nWindows\n- Make sure to use a full path if the binary name doesn't work\n- Remove `--login` in shellArgs\n\nWindows Subsystem for Linux (WSL) - previously Bash on Windows\n- Example: `C:\\\\Windows\\\\System32\\\\wsl.exe`\n\nGit-bash on Windows\n- Example: `C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe`\n\nPowerShell on Windows\n- Example: `C:\\\\WINDOWS\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe`\n\nCygwin\n- Example: `C:\\\\cygwin64\\\\bin\\\\bash.exe`\n\nGit Bash\n- Example: `C:\\\\Program Files\\\\Git\\\\git-cmd.exe`\nThen Add `--command=usr/bin/bash.exe` to shellArgs",
"type": "string"
},
"shellArgs": {
"description": "for setting shell arguments (e.g. for using interactive shellArgs: `['-i']`)\nby default `['--login']` will be used",
"items": {
"type": "string"
},
"type": "array"
},
"showHamburgerMenu": {
"description": "if you're using a Linux setup which show native menus, set to false\n\ndefault: `true` on Linux, `true` on Windows, ignored on macOS",
"enum": [
"",
false,
true
]
},
"showWindowControls": {
"description": "set to `false` if you want to hide the minimize, maximize and close buttons\n\nadditionally, set to `'left'` if you want them on the left, like in Ubuntu\n\ndefault: `true` on Windows and Linux, ignored on macOS",
"enum": [
"",
false,
"left",
true
]
},
"termCSS": {
"description": "custom CSS to embed in the terminal window",
"type": "string"
},
"uiFontFamily": {
"type": "string"
},
"webGLRenderer": {
"description": "Whether to use the WebGL renderer. Set it to false to use canvas-based\nrendering (slower, but supports transparent backgrounds)",
"type": "boolean"
},
"webLinksActivationKey": {
"description": "keypress required for weblink activation: [ctrl | alt | meta | shift]",
"enum": [
"",
"alt",
"ctrl",
"meta",
"shift"
],
"type": "string"
},
"windowSize": {
"description": "Initial window size in pixels",
"items": [
{
"type": "number"
},
{
"type": "number"
}
],
"maxItems": 2,
"minItems": 2,
"type": "array"
},
"workingDirectory": {
"description": "set custom startup directory (must be an absolute path)",
"type": "string"
}
},
"required": [
"backgroundColor",
"bell",
"bellSound",
"bellSoundURL",
"borderColor",
"colors",
"copyOnSelect",
"css",
"cursorAccentColor",
"cursorBlink",
"cursorColor",
"cursorShape",
"disableLigatures",
"env",
"fontFamily",
"fontSize",
"fontWeight",
"fontWeightBold",
"foregroundColor",
"imageSupport",
"letterSpacing",
"lineHeight",
"macOptionSelectionMode",
"padding",
"preserveCWD",
"quickEdit",
"screenReaderMode",
"scrollback",
"selectionColor",
"shell",
"shellArgs",
"showHamburgerMenu",
"showWindowControls",
"termCSS",
"webGLRenderer",
"webLinksActivationKey",
"workingDirectory"
],
"type": "object"
},
{
"properties": {
"defaultProfile": {
"description": "The default profile name to use when launching a new session",
"type": "string"
},
"profiles": {
"description": "A list of profiles to use",
"items": {
"properties": {
"config": {
"$ref": "#/definitions/Partial<profileConfigOptions>",
"description": "Specify all the options you want to override for each profile.\nOptions set here override the defaults set in the root."
},
"name": {
"type": "string"
}
},
"required": [
"config",
"name"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"defaultProfile",
"profiles"
],
"type": "object"
}
]
}
},
"properties": {
"config": {
"$ref": "#/definitions/configOptions"
},
"keymaps": {
"additionalProperties": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
]
},
"description": "Example\n'window:devtools': 'cmd+alt+o',",
"type": "object"
},
"localPlugins": {
"description": "in development, you can create a directory under\n`plugins/local/` and include it here\nto load it and avoid it being `npm install`ed",
"items": {
"type": "string"
},
"type": "array"
},
"plugins": {
"description": "a list of plugins to fetch and install from npm\nformat: [@org/]project[#version]\nexamples:\n `hyperpower`\n `@company/project`\n `project#1.0.1`",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
}

View file

@ -1,21 +0,0 @@
import type {BrowserWindow} from 'electron';
import Config from 'electron-store';
export const defaults = {
windowPosition: [50, 50] as [number, number],
windowSize: [540, 380] as [number, number]
};
// local storage
const cfg = new Config({defaults});
export function get() {
const position = cfg.get('windowPosition', defaults.windowPosition);
const size = cfg.get('windowSize', defaults.windowSize);
return {position, size};
}
export function recordState(win: BrowserWindow) {
cfg.set('windowPosition', win.getPosition());
cfg.set('windowSize', win.getSize());
}

View file

@ -1,38 +1,26 @@
<!doctype html>
<html>
<head>
<title>Hyper</title>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">
<title>HyperTerm</title>
<meta name="viewport" content="initial-scale=1.0" />
<style>
body {
color: #fff;
-webkit-backface-visibility: hidden;
}
* {
margin: 0;
padding: 0;
text-rendering: optimizeLegibility;
text-rendering: geometricPrecision;
box-sizing: border-box;
}
@media (min-resolution: 2dppx) {
* {
text-rendering: geometricPrecision;
}
}
</style>
</head>
<body>
<div id="mount"></div>
<script>start = performance.now();</script>
<script src="renderer/bundle.js"></script>
<script src="dist/bundle.js"></script>
<script>console.log('total init time', performance.now() - start);</script>
</body>
</html>

View file

@ -1,243 +0,0 @@
// eslint-disable-next-line import/order
import {cfgPath} from './config/paths';
// Print diagnostic information for a few arguments instead of running Hyper.
if (['--help', '-v', '--version'].includes(process.argv[1])) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const {version} = require('./package');
console.log(`Hyper version ${version}`);
console.log('Hyper does not accept any command line arguments. Please modify the config file instead.');
console.log(`Hyper configuration file located at: ${cfgPath}`);
process.exit();
}
// Enable remote module
// eslint-disable-next-line import/order
import {initialize as remoteInitialize} from '@electron/remote/main';
remoteInitialize();
// set up config
// eslint-disable-next-line import/order
import * as config from './config';
config.setup();
// Native
import {resolve} from 'path';
// Packages
import {app, BrowserWindow, Menu, screen} from 'electron';
import isDev from 'electron-is-dev';
import {gitDescribe} from 'git-describe';
import parseUrl from 'parse-url';
import * as AppMenu from './menus/menu';
import * as plugins from './plugins';
import {newWindow} from './ui/window';
import {installCLI} from './utils/cli-install';
import * as windowUtils from './utils/window-utils';
const windowSet = new Set<BrowserWindow>([]);
// expose to plugins
app.config = config;
app.plugins = plugins;
app.getWindows = () => new Set([...windowSet]); // return a clone
// function to retrieve the last focused window in windowSet;
// added to app object in order to expose it to plugins.
app.getLastFocusedWindow = () => {
if (!windowSet.size) {
return null;
}
return Array.from(windowSet).reduce((lastWindow, win) => {
return win.focusTime > lastWindow.focusTime ? win : lastWindow;
});
};
console.log('Disabling Chromium GPU blacklist');
app.commandLine.appendSwitch('ignore-gpu-blacklist');
if (isDev) {
console.log('running in dev mode');
// Override default appVersion which is set from package.json
gitDescribe({customArguments: ['--tags']}, (error: any, gitInfo: {raw: string}) => {
if (!error) {
app.setVersion(gitInfo.raw);
}
});
} else {
console.log('running in prod mode');
}
const url = `file://${resolve(isDev ? __dirname : app.getAppPath(), 'index.html')}`;
console.log('electron will open', url);
async function installDevExtensions(isDev_: boolean) {
if (!isDev_) {
return [];
}
const {default: installer, REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS} = await import('electron-devtools-installer');
const extensions = [REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS];
const forceDownload = Boolean(process.env.UPGRADE_EXTENSIONS);
return Promise.all(
extensions.map((extension) => installer(extension, {forceDownload, loadExtensionOptions: {allowFileAccess: true}}))
);
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
app.on('ready', () =>
installDevExtensions(isDev)
.then(() => {
function createWindow(
fn?: (win: BrowserWindow) => void,
options: {size?: [number, number]; position?: [number, number]} = {},
profileName: string = config.getDefaultProfile()
) {
const cfg = plugins.getDecoratedConfig(profileName);
const winSet = config.getWin();
let [startX, startY] = winSet.position;
const [width, height] = options.size ? options.size : cfg.windowSize || winSet.size;
const winPos = options.position;
// Open the new window roughly the height of the header away from the
// previous window. This also ensures in multi monitor setups that the
// new terminal is on the correct screen.
const focusedWindow = BrowserWindow.getFocusedWindow() || app.getLastFocusedWindow();
// In case of options defaults position and size, we should ignore the focusedWindow.
if (winPos !== undefined) {
[startX, startY] = winPos;
} else if (focusedWindow) {
const points = focusedWindow.getPosition();
const currentScreen = screen.getDisplayNearestPoint({
x: points[0],
y: points[1]
});
const biggestX = points[0] + 100 + width - currentScreen.bounds.x;
const biggestY = points[1] + 100 + height - currentScreen.bounds.y;
if (biggestX > currentScreen.size.width) {
startX = 50;
} else {
startX = points[0] + 34;
}
if (biggestY > currentScreen.size.height) {
startY = 50;
} else {
startY = points[1] + 34;
}
}
if (!windowUtils.positionIsValid([startX, startY])) {
[startX, startY] = config.windowDefaults.windowPosition;
}
const hwin = newWindow({width, height, x: startX, y: startY}, cfg, fn, profileName);
windowSet.add(hwin);
void hwin.loadURL(url);
// the window can be closed by the browser process itself
hwin.on('close', () => {
hwin.clean();
windowSet.delete(hwin);
});
return hwin;
}
// when opening create a new window
createWindow();
// expose to plugins
app.createWindow = createWindow;
// mac only. when the dock icon is clicked
// and we don't have any active windows open,
// we open one
app.on('activate', () => {
if (!windowSet.size) {
createWindow();
}
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
const makeMenu = () => {
const menu = plugins.decorateMenu(AppMenu.createMenu(createWindow, plugins.getLoadedPluginVersions));
// If we're on Mac make a Dock Menu
if (process.platform === 'darwin') {
const dockMenu = Menu.buildFromTemplate([
{
label: 'New Window',
click() {
createWindow();
}
}
]);
app.dock.setMenu(dockMenu);
}
Menu.setApplicationMenu(AppMenu.buildMenu(menu));
};
plugins.onApp(app);
makeMenu();
plugins.subscribe(plugins.onApp.bind(undefined, app));
config.subscribe(makeMenu);
if (!isDev) {
// check if should be set/removed as default ssh protocol client
if (config.getConfig().defaultSSHApp && !app.isDefaultProtocolClient('ssh')) {
console.log('Setting Hyper as default client for ssh:// protocol');
app.setAsDefaultProtocolClient('ssh');
} else if (!config.getConfig().defaultSSHApp && app.isDefaultProtocolClient('ssh')) {
console.log('Removing Hyper from default client for ssh:// protocol');
app.removeAsDefaultProtocolClient('ssh');
}
void installCLI(false);
}
})
.catch((err) => {
console.error('Error while loading devtools extensions', err);
})
);
/**
* Get last focused BrowserWindow or create new if none and callback
* @param callback Function to call with the BrowserWindow
*/
function GetWindow(callback: (win: BrowserWindow) => void) {
const lastWindow = app.getLastFocusedWindow();
if (lastWindow) {
callback(lastWindow);
} else if (!lastWindow && {}.hasOwnProperty.call(app, 'createWindow')) {
app.createWindow(callback);
} else {
// If createWindow doesn't exist yet ('ready' event was not fired),
// sets his callback to an app.windowCallback property.
app.windowCallback = callback;
}
}
app.on('open-file', (_event, path) => {
GetWindow((win: BrowserWindow) => {
win.rpc.emit('open file', {path});
});
});
app.on('open-url', (_event, sshUrl) => {
GetWindow((win: BrowserWindow) => {
win.rpc.emit('open ssh', parseUrl(sshUrl));
});
});

View file

@ -1,55 +0,0 @@
{
"window:devtools": "command+alt+i",
"window:reload": "command+shift+r",
"window:reloadFull": "command+shift+f5",
"window:preferences": "command+,",
"zoom:reset": "command+0",
"zoom:in": [
"command+plus",
"command+="
],
"zoom:out": "command+-",
"window:new": "command+n",
"window:minimize": "command+m",
"window:zoom": "ctrl+alt+command+m",
"window:toggleFullScreen": "command+ctrl+f",
"window:close": "command+shift+w",
"tab:new": "command+t",
"tab:next": [
"command+shift+]",
"command+shift+right",
"command+alt+right",
"ctrl+tab"
],
"tab:prev": [
"command+shift+[",
"command+shift+left",
"command+alt+left",
"ctrl+shift+tab"
],
"tab:jump:prefix": "command",
"pane:next": "command+]",
"pane:prev": "command+[",
"pane:splitRight": "command+d",
"pane:splitDown": "command+shift+d",
"pane:close": "command+w",
"editor:undo": "command+z",
"editor:redo": "command+y",
"editor:cut": "command+x",
"editor:copy": "command+c",
"editor:paste": "command+v",
"editor:selectAll": "command+a",
"editor:search": "command+f",
"editor:search-close": "esc",
"editor:movePreviousWord": "alt+left",
"editor:moveNextWord": "alt+right",
"editor:moveBeginningLine": "command+left",
"editor:moveEndLine": "command+right",
"editor:deletePreviousWord": "alt+backspace",
"editor:deleteNextWord": "alt+delete",
"editor:deleteBeginningLine": "command+backspace",
"editor:deleteEndLine": "command+delete",
"editor:clearBuffer": "command+k",
"editor:break": "ctrl+c",
"plugins:update": "command+shift+u"
}

View file

@ -1,53 +0,0 @@
{
"window:devtools": "ctrl+shift+i",
"window:reload": "ctrl+shift+r",
"window:reloadFull": "ctrl+shift+f5",
"window:preferences": "ctrl+,",
"window:hamburgerMenu": "alt+f",
"zoom:reset": "ctrl+0",
"zoom:in": "ctrl+=",
"zoom:out": "ctrl+-",
"window:new": "ctrl+shift+n",
"window:minimize": "ctrl+shift+m",
"window:zoom": "ctrl+shift+alt+m",
"window:toggleFullScreen": "f11",
"window:close": "ctrl+shift+q",
"tab:new": "ctrl+shift+t",
"tab:next": [
"ctrl+shift+]",
"ctrl+shift+right",
"ctrl+alt+right",
"ctrl+tab"
],
"tab:prev": [
"ctrl+shift+[",
"ctrl+shift+left",
"ctrl+alt+left",
"ctrl+shift+tab"
],
"tab:jump:prefix": "ctrl",
"pane:next": "ctrl+pageup",
"pane:prev": "ctrl+pagedown",
"pane:splitRight": "ctrl+shift+d",
"pane:splitDown": "ctrl+shift+e",
"pane:close": "ctrl+shift+w",
"editor:undo": "ctrl+shift+z",
"editor:redo": "ctrl+shift+y",
"editor:cut": "ctrl+shift+x",
"editor:copy": "ctrl+shift+c",
"editor:paste": "ctrl+shift+v",
"editor:selectAll": "ctrl+shift+a",
"editor:search": "ctrl+shift+f",
"editor:search-close": "esc",
"editor:movePreviousWord": "ctrl+left",
"editor:moveNextWord": "ctrl+right",
"editor:moveBeginningLine": "home",
"editor:moveEndLine": "end",
"editor:deletePreviousWord": "ctrl+backspace",
"editor:deleteNextWord": "ctrl+del",
"editor:deleteBeginningLine": "ctrl+home",
"editor:deleteEndLine": "ctrl+end",
"editor:clearBuffer": "ctrl+shift+k",
"editor:break": "ctrl+c",
"plugins:update": "ctrl+shift+u"
}

View file

@ -1,50 +0,0 @@
{
"window:devtools": "ctrl+shift+i",
"window:reload": "ctrl+shift+r",
"window:reloadFull": "ctrl+shift+f5",
"window:preferences": "ctrl+,",
"window:hamburgerMenu": "alt+f",
"zoom:reset": "ctrl+0",
"zoom:in": "ctrl+=",
"zoom:out": "ctrl+-",
"window:new": "ctrl+shift+n",
"window:minimize": "ctrl+shift+m",
"window:zoom": "ctrl+shift+alt+m",
"window:toggleFullScreen": "f11",
"window:close": [
"ctrl+shift+q",
"alt+f4"
],
"tab:new": "ctrl+shift+t",
"tab:next": [
"ctrl+tab"
],
"tab:prev": [
"ctrl+shift+tab"
],
"tab:jump:prefix": "ctrl",
"pane:next": "ctrl+pageup",
"pane:prev": "ctrl+pagedown",
"pane:splitRight": "ctrl+shift+d",
"pane:splitDown": "ctrl+shift+e",
"pane:close": "ctrl+shift+w",
"editor:undo": "ctrl+shift+z",
"editor:redo": "ctrl+shift+y",
"editor:cut": "ctrl+shift+x",
"editor:copy": "ctrl+shift+c",
"editor:paste": "ctrl+shift+v",
"editor:selectAll": "ctrl+shift+a",
"editor:search": "ctrl+shift+f",
"editor:search-close": "esc",
"editor:movePreviousWord": "",
"editor:moveNextWord": "",
"editor:moveBeginningLine": "Home",
"editor:moveEndLine": "End",
"editor:deletePreviousWord": "ctrl+backspace",
"editor:deleteNextWord": "ctrl+del",
"editor:deleteBeginningLine": "ctrl+home",
"editor:deleteEndLine": "ctrl+end",
"editor:clearBuffer": "ctrl+shift+k",
"editor:break": "ctrl+c",
"plugins:update": "ctrl+shift+u"
}

15
app/lib/actions/config.js Normal file
View file

@ -0,0 +1,15 @@
import { CONFIG_LOAD, CONFIG_RELOAD } from '../constants/config';
export function loadConfig (config) {
return {
type: CONFIG_LOAD,
config
};
}
export function reloadConfig (config) {
return {
type: CONFIG_RELOAD,
config
};
}

26
app/lib/actions/header.js Normal file
View file

@ -0,0 +1,26 @@
import { CLOSE_TAB, CHANGE_TAB } from '../constants/tabs';
import { userExitSession, setActiveSession } from './sessions';
export function closeTab (uid) {
return (dispatch, getState) => {
dispatch({
type: CLOSE_TAB,
uid,
effect () {
dispatch(userExitSession(uid));
}
});
};
}
export function changeTab (uid) {
return (dispatch, getState) => {
dispatch({
type: CHANGE_TAB,
uid,
effect () {
dispatch(setActiveSession(uid));
}
});
};
}

7
app/lib/actions/index.js Normal file
View file

@ -0,0 +1,7 @@
import { requestSession } from './sessions';
export function init () {
return (dispatch) => {
dispatch(requestSession());
};
}

View file

@ -0,0 +1,8 @@
import { NOTIFICATION_DISMISS } from '../constants/notifications';
export function dismissNotification (id) {
return {
type: NOTIFICATION_DISMISS,
id
};
}

192
app/lib/actions/sessions.js Normal file
View file

@ -0,0 +1,192 @@
import rpc from '../rpc';
import getURL from '../utils/url-command';
import { keys } from '../utils/object';
import {
SESSION_ADD,
SESSION_RESIZE,
SESSION_REQUEST,
SESSION_ADD_DATA,
SESSION_PTY_DATA,
SESSION_PTY_EXIT,
SESSION_USER_EXIT,
SESSION_USER_EXIT_ACTIVE,
SESSION_SET_ACTIVE,
SESSION_CLEAR_ACTIVE,
SESSION_USER_DATA,
SESSION_URL_SET,
SESSION_URL_UNSET,
SESSION_SET_XTERM_TITLE,
SESSION_SET_PROCESS_TITLE
} from '../constants/sessions';
export function addSession (uid) {
return (dispatch, getState) => {
const { sessions } = getState();
// normally this would be encoded as an effect
// but the `SESSION_ADD` action is pretty expensive
// and we want to get this out as soon as possible
const initial = null == sessions.activeUid;
if (initial) rpc.emit('init');
dispatch({
type: SESSION_ADD,
uid
});
};
}
export function requestSession (uid) {
return (dispatch, getState) => {
const { ui } = getState();
const cols = ui.cols;
const rows = ui.rows;
dispatch({
type: SESSION_REQUEST,
effect: () => {
rpc.emit('new', { cols, rows });
}
});
};
}
export function addSessionData (uid, data) {
return (dispatch, getState) => {
dispatch({
type: SESSION_ADD_DATA,
data,
effect () {
const url = getURL(data);
if (null != url) {
dispatch({
type: SESSION_URL_SET,
uid,
url
});
} else {
dispatch({
type: SESSION_PTY_DATA,
uid,
data
});
}
}
});
};
}
export function sessionExit (uid) {
return (dispatch, getState) => {
return dispatch({
type: SESSION_PTY_EXIT,
uid,
effect () {
// we reiterate the same logic as below
// for SESSION_USER_EXIT since the exit
// could happen pty side or optimistic
const sessions = keys(getState().sessions.sessions);
if (!sessions.length) {
window.close();
}
}
});
};
}
// we want to distinguish an exit
// that's UI initiated vs pty initiated
export function userExitSession (uid) {
return (dispatch, getState) => {
return dispatch({
type: SESSION_USER_EXIT,
uid,
effect () {
rpc.emit('exit', { uid });
const sessions = keys(getState().sessions.sessions);
if (!sessions.length) {
window.close();
}
}
});
};
}
export function userExitActiveSession () {
return (dispatch, getState) => {
dispatch({
type: SESSION_USER_EXIT_ACTIVE,
effect () {
const uid = getState().sessions.activeUid;
dispatch(userExitSession(uid));
}
});
};
}
export function setActiveSession (uid) {
return (dispatch, getState) => {
const state = getState();
const prevUid = state.sessions.activeUid;
dispatch({
type: SESSION_SET_ACTIVE,
uid,
effect () {
// TODO: this goes away when we are able to poll
// for the title ourseleves, instead of relying
// on Session and focus/blur to subscribe
if (prevUid) rpc.emit('blur', { uid: prevUid });
rpc.emit('focus', { uid });
}
});
};
}
export function clearActiveSession () {
return {
type: SESSION_CLEAR_ACTIVE
};
}
export function setSessionProcessTitle (uid, title) {
return {
type: SESSION_SET_PROCESS_TITLE,
uid,
title
};
}
export function setSessionXtermTitle (uid, title) {
return {
type: SESSION_SET_XTERM_TITLE,
uid,
title
};
}
export function resizeSession (uid, cols, rows) {
return {
type: SESSION_RESIZE,
cols,
rows,
effect () {
rpc.emit('resize', { cols, rows });
}
};
}
export function sendSessionData (uid, data) {
return {
type: SESSION_USER_DATA,
data,
effect () {
rpc.emit('data', { uid, data });
}
};
}
export function exitSessionBrowser (uid) {
return {
type: SESSION_URL_UNSET,
uid
};
}

143
app/lib/actions/ui.js Normal file
View file

@ -0,0 +1,143 @@
import { setActiveSession } from './sessions';
import { keys } from '../utils/object';
import { last } from '../utils/array';
import rpc from '../rpc';
import {
requestSession,
sendSessionData
} from '../actions/sessions';
import {
UI_FONT_SIZE_SET,
UI_FONT_SIZE_INCR,
UI_FONT_SIZE_DECR,
UI_FONT_SIZE_RESET,
UI_MOVE_LEFT,
UI_MOVE_RIGHT,
UI_MOVE_TO,
UI_SHOW_PREFERENCES
} from '../constants/ui';
export function increaseFontSize () {
return (dispatch, getState) => {
dispatch({
type: UI_FONT_SIZE_INCR,
effect () {
const state = getState();
const old = state.ui.fontSizeOverride || state.ui.fontSize;
const value = old + 1;
dispatch({
type: UI_FONT_SIZE_SET,
value
});
}
});
};
}
export function decreaseFontSize () {
return (dispatch, getState) => {
dispatch({
type: UI_FONT_SIZE_DECR,
effect () {
const state = getState();
const old = state.ui.fontSizeOverride || state.ui.fontSize;
const value = old - 1;
dispatch({
type: UI_FONT_SIZE_SET,
value
});
}
});
};
}
export function resetFontSize () {
return {
type: UI_FONT_SIZE_RESET
};
}
export function moveLeft () {
return (dispatch, getState) => {
dispatch({
type: UI_MOVE_LEFT,
effect () {
const { sessions } = getState();
const uid = sessions.activeUid;
const sessionUids = keys(sessions.sessions);
const index = sessionUids.indexOf(uid);
const next = sessionUids[index - 1] || last(sessionUids);
if (!next || uid === next) {
console.log('ignoring left move action');
} else {
dispatch(setActiveSession(next));
}
}
});
};
}
export function moveRight () {
return (dispatch, getState) => {
dispatch({
type: UI_MOVE_RIGHT,
effect () {
const { sessions } = getState();
const uid = sessions.activeUid;
const sessionUids = keys(sessions.sessions);
const index = sessionUids.indexOf(uid);
const next = sessionUids[index + 1] || sessionUids[0];
if (!next || uid === next) {
console.log('ignoring right move action');
} else {
dispatch(setActiveSession(next));
}
}
});
};
}
export function moveTo (i) {
return (dispatch, getState) => {
dispatch({
type: UI_MOVE_TO,
index: i,
effect () {
const { sessions } = getState();
const uid = sessions.activeUid;
const sessionUids = keys(sessions.sessions);
if (uid === sessionUids[i]) {
console.log('ignoring same uid');
} else if (null != sessionUids[i]) {
dispatch(setActiveSession(sessionUids[i]));
} else {
console.log('ignoring inexistent index', i);
}
}
});
};
}
export function showPreferences () {
return (dispatch, getState) => {
dispatch({
type: UI_SHOW_PREFERENCES,
effect () {
dispatch(requestSession());
// TODO: replace this hack with an async action
rpc.once('session add', ({ uid }) => {
rpc.once('session data', () => {
dispatch(sendSessionData(
uid,
['# Attempting to open ~/.hyperterm.js with your $EDITOR',
'# If this doesn\'t work, open it manually with your favorite editor!',
'$EDITOR ~/.hyperterm.js && exit',
''
].join('\n')
));
});
});
}
});
};
}

View file

@ -0,0 +1,22 @@
import {
UPDATE_INSTALL,
UPDATE_AVAILABLE
} from '../constants/updater';
import rpc from '../rpc';
export function installUpdate () {
return {
type: UPDATE_INSTALL,
effect: () => {
rpc.emit('quit and install');
}
};
}
export function updateAvailable (version, notes) {
return {
type: UPDATE_AVAILABLE,
version,
notes
};
}

70
app/lib/component.js Normal file
View file

@ -0,0 +1,70 @@
import React from 'react';
import { StyleSheet, css } from 'aphrodite';
import { shouldComponentUpdate } from 'react-addons-pure-render-mixin';
export default class Component extends React.Component {
constructor () {
super();
this.styles_ = this.createStyleSheet();
this.cssHelper = this.cssHelper.bind(this);
if (!this.shouldComponentUpdate) {
this.shouldComponentUpdate = shouldComponentUpdate.bind(this);
}
}
createStyleSheet () {
if (!this.styles) {
return {};
}
const styles = this.styles();
if ('object' !== typeof styles) {
throw new TypeError('Component `styles` returns a non-object');
}
return StyleSheet.create(this.styles());
}
// wrap aphrodite's css helper for two reasons:
// - we can give the element an unaltered global classname
// that can be used to introduce global css side effects
// for example, through the configuration, web inspector
// or user agente extensions
// - the user doesn't need to keep track of both `css`
// and `style`, and we make that whole ordear easier
cssHelper (...args) {
const classes = args
.map((c) => {
if (c) {
// we compute the global name from the given
// css class and we prepend the component name
// it's important classes never get mangled by
// uglifiers so that we can avoid collisions
const component = this.constructor.name
.toString()
.toLowerCase();
const globalName = `${component}_${c}`;
return [globalName, css(this.styles_[c])];
}
})
// skip nulls
.filter((v) => !!v)
// flatten
.reduce((a, b) => a.concat(b));
return classes.length ? classes.join(' ') : null;
}
render () {
// convert static objects from `babel-plugin-transform-jsx`
// to `React.Element`.
if (!this.template) {
throw new TypeError("Component doesn't define `template`");
}
// invoke the template creator passing our css helper
return this.template(this.cssHelper);
}
}

View file

@ -0,0 +1,92 @@
import React from 'react';
import Tabs_ from './tabs';
import overrideStyles from '../utils/override-style';
import Component from '../component';
import { decorate, getTabsProps } from '../utils/plugins';
const Tabs = decorate(Tabs_, 'Tabs');
export default class Header extends Component {
constructor () {
super();
this.onChangeIntent = this.onChangeIntent.bind(this);
this.onHeaderMouseDown = this.onHeaderMouseDown.bind(this);
}
onChangeIntent (active) {
// we ignore clicks if they're a byproduct of a drag
// motion to move the window
if (window.screenX !== this.headerMouseDownWindowX ||
window.screenY !== this.headerMouseDownWindowY) {
return;
}
this.props.onChangeTab(active);
}
onHeaderMouseDown () {
this.headerMouseDownWindowX = window.screenX;
this.headerMouseDownWindowY = window.screenY;
this.clicks = this.clicks || 1;
if (this.clicks++ >= 2) {
if (this.maximized) {
this.rpc.emit('unmaximize');
} else {
this.rpc.emit('maximize');
}
this.clicks = 0;
this.maximized = !this.maximized;
} else {
// http://www.quirksmode.org/dom/events/click.html
// https://en.wikipedia.org/wiki/Double-click
this.clickTimer = setTimeout(() => {
this.clicks = 0;
}, 500);
}
}
componentWillUnmount () {
delete this.clicks;
clearTimeout(this.clickTimer);
}
template (css) {
const { isMac, backgroundColor } = this.props;
const props = getTabsProps(this.props, {
tabs: this.props.tabs,
borderColor: this.props.borderColor,
onClose: this.props.onCloseTab,
onChange: this.onChangeIntent
});
return <header
ref={ overrideStyles({ backgroundColor }) }
className={ css('header', isMac && 'headerRounded') }
onMouseDown={ this.onHeaderMouseDown }>
{ this.props.customChildrenBefore }
<Tabs {...props} />
{ this.props.customChildren }
</header>;
}
styles () {
return {
header: {
position: 'fixed',
top: '1px',
left: '1px',
right: '1px',
background: '#000',
zIndex: '100'
},
headerRounded: {
borderTopLeftRadius: '6px',
borderTopRightRadius: '6px'
}
};
}
}

View file

@ -0,0 +1,116 @@
import React from 'react';
import Component from '../component';
export default class Notification extends Component {
constructor () {
super();
this.state = {
dismissing: false
};
this.dismiss = this.dismiss.bind(this);
this.onElement = this.onElement.bind(this);
}
componentDidMount () {
if (this.props.dismissAfter) {
this.setDismissTimer();
}
}
componentWillReceiveProps (next) {
// if we have a timer going and the notification text
// changed we rest the timer
if (next.text !== this.props.text) {
if (this.props.dismissAfter) {
this.resetDismissTimer();
}
if (this.state.dismissing) {
this.setState({ dismissing: false });
}
}
}
dismiss () {
this.setState({ dismissing: true });
}
onElement (el) {
if (el) {
el.addEventListener('webkitTransitionEnd', () => {
if (this.state.dismissing) {
this.props.onDismiss();
}
});
// aprhodie !important override :(
const { backgroundColor } = this.props;
if (backgroundColor) {
el.style.setProperty(
'background-color',
backgroundColor,
'important'
);
}
}
}
setDismissTimer (after) {
this.dismissTimer = setTimeout(() => {
this.dismiss();
}, this.props.dismissAfter);
}
resetDismissTimer (after) {
clearTimeout(this.dismissTimer);
this.setDismissTimer();
}
componentWillUnmount () {
clearTimeout(this.dismissTimer);
}
template (css) {
const { backgroundColor } = this.props;
const opacity = this.state.dismissing ? 0 : 1;
return <div
style={{ opacity, backgroundColor }}
ref={ this.onElement }
className={ css('indicator') }>
{ this.props.customChildrenBefore }
{ this.props.children || this.props.text }
{
this.props.userDismissable
? <a
className={ css('dismissLink') }
onClick={ this.dismiss }>[x]</a>
: null
}
{ this.props.customChildren }
</div>;
}
styles () {
return {
indicator: {
display: 'inline-block',
cursor: 'default',
WebkitUserSelect: 'none',
background: 'rgba(255, 255, 255, .2)',
padding: '6px 14px',
marginLeft: '10px',
transition: '150ms opacity ease',
color: '#fff',
font: '11px Menlo'
},
dismissLink: {
cursor: 'pointer',
color: '#528D11',
':hover': {
color: '#2A5100'
}
}
};
}
}

View file

@ -0,0 +1,70 @@
import React from 'react';
import Component from '../component';
import Notification_ from './notification';
import { decorate } from '../utils/plugins';
const Notification = decorate(Notification_);
export default class Notifications extends Component {
template (css) {
return <div className={ css('view') }>
{ this.props.customChildrenBefore }
{
this.props.fontShowing &&
<Notification
key='font'
backgroundColor='rgba(255, 255, 255, .2)'
text={`${this.props.fontSize}px`}
userDismissable={ false }
onDismiss={ this.props.onDismissFont }
dismissAfter={ 1000 } />
}
{
this.props.resizeShowing &&
<Notification
key='resize'
backgroundColor='rgba(255, 255, 255, .2)'
text={`${this.props.cols}x${this.props.rows}`}
userDismissable={ false }
onDismiss={ this.props.onDismissResize }
dismissAfter={ 1000 } />
}
{
this.props.updateShowing &&
<Notification
key='update'
backgroundColor='#7ED321'
text={`Version ${this.props.updateVersion} available`}
onDismiss={ this.props.onDismissUpdate }
userDismissable={ true }>
Version <b>{ this.props.updateVersion}</b> available.
{ this.props.updateNote && ` ${this.props.updateNote.trim().replace(/\.$/, '')}.` }
{ ' ' }
<a style={{
cursor: 'pointer',
textDecoration: 'underline',
fontWeight: 'bold' }}
onClick={ this.props.onUpdateInstall }>
Restart
</a>
{ ' ' }
</Notification>
}
{ this.props.customChildren }
</div>;
}
styles () {
return {
view: {
position: 'fixed',
bottom: '20px',
right: '20px'
}
};
}
}

175
app/lib/components/tab.js Normal file
View file

@ -0,0 +1,175 @@
import React from 'react';
import Component from '../component';
import overrideStyle from '../utils/override-style';
export default class Tab extends Component {
constructor () {
super();
this.hover = this.hover.bind(this);
this.blur = this.blur.bind(this);
this.state = {
hovered: false
};
}
hover () {
this.setState({ hovered: true });
}
blur () {
this.setState({ hovered: false });
}
template (css) {
const { isActive, isFirst, isLast, borderColor, hasActivity } = this.props;
const { hovered } = this.state;
return <li
onMouseEnter={ this.hover }
onMouseLeave={ this.blur }
onClick={ this.props.onClick }
ref={ overrideStyle({ borderColor }) }
className={ css(
'tab',
isFirst && 'first',
isActive && 'active',
hasActivity && 'hasActivity'
) }>
{ this.props.customChildrenBefore }
<span
className={ css(
'text',
isLast && 'textLast',
isActive && 'textActive'
) }
onClick={ this.props.isActive ? null : this.props.onSelect }>
<span className={ css('textInner') }>
{ this.props.text }
</span>
</span>
<i
className={ css(
'icon',
hovered && 'iconHovered'
) }
onClick={ this.props.onClose }>
<svg className={ css('shape') }>
<use xlinkHref='assets/icons.svg#close'></use>
</svg>
</i>
{ this.props.customChildren }
</li>;
}
styles () {
return {
tab: {
color: '#ccc',
listStyleType: 'none',
flexGrow: 1,
position: 'relative',
':hover': {
color: '#ccc'
}
},
first: {
marginLeft: '76px'
},
active: {
color: '#fff',
':before': {
position: 'absolute',
content: '" "',
borderBottom: '1px solid #000',
display: 'block',
left: '0px',
right: '0px',
bottom: '-1px'
},
':hover': {
color: '#fff'
}
},
hasActivity: {
color: '#50E3C2',
':hover': {
color: '#50E3C2'
}
},
text: {
transition: 'color .2s ease',
height: '34px',
display: 'block',
width: '100%',
position: 'relative',
borderLeft: '1px solid transparent',
borderRight: '1px solid transparent',
overflow: 'hidden'
},
textInner: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
textAlign: 'center'
},
textLast: {
borderRightWidth: 0
},
textActive: {
borderColor: 'inherit'
},
icon: {
transition: `opacity .2s ease, color .2s ease,
transform .25s ease, background-color .1s ease`,
pointerEvents: 'none',
position: 'absolute',
right: '7px',
top: '10px',
display: 'inline-block',
width: '14px',
height: '14px',
borderRadius: '100%',
color: '#e9e9e9',
opacity: 0,
transform: 'scale(.95)',
':hover': {
backgroundColor: 'rgba(255,255,255, .13)',
color: '#fff'
},
':active': {
backgroundColor: 'rgba(255,255,255, .1)',
color: '#909090'
}
},
iconHovered: {
opacity: 1,
transform: 'none',
pointerEvents: 'all'
},
shape: {
position: 'absolute',
left: '4px',
top: '4px',
width: '6px',
height: '6px',
verticalAlign: 'middle',
fill: 'currentColor'
}
};
}
}

View file

@ -0,0 +1,81 @@
import Tab_ from './tab';
import React from 'react';
import Component from '../component';
import overrideStyle from '../utils/override-style';
import { decorate, getTabProps } from '../utils/plugins';
const Tab = decorate(Tab_, 'Tab');
export default class Tabs extends Component {
template (css) {
const {
tabs = [],
borderColor,
onChange,
onClose
} = this.props;
return <nav className={ css('nav') }>
{
tabs.length
? 1 === tabs.length
? <div className={ css('title') }>{ tabs[0].title }</div>
: <ul
ref={overrideStyle({ borderColor })}
className={ css('list') }>
{
tabs.map((tab, i) => {
const { uid, title, isActive, hasActivity } = tab;
const props = getTabProps(tab, this.props, {
text: '' === title ? 'Shell' : title,
isFirst: 0 === i,
isLast: tabs.length - 1 === i,
borderColor: borderColor,
isActive,
hasActivity,
onSelect: onChange.bind(null, uid),
onClose: onClose.bind(null, uid)
});
return <Tab key={`tab-${uid}`} {...props} />;
})
}
</ul>
: null
}
{ this.props.customChildren }
</nav>;
}
styles () {
return {
nav: {
fontSize: '12px',
fontFamily: `-apple-system, BlinkMacSystemFont,
"Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans",
"Droid Sans", "Helvetica Neue", sans-serif`,
height: '34px',
lineHeight: '34px',
verticalAlign: 'middle',
color: '#9B9B9B',
cursor: 'default',
WebkitUserSelect: 'none',
WebkitAppRegion: 'drag'
},
title: {
textAlign: 'center',
color: '#fff'
},
list: {
borderBottom: '1px solid #333',
maxHeight: '34px',
display: 'flex',
flexFlow: 'row'
}
};
}
}

206
app/lib/components/term.js Normal file
View file

@ -0,0 +1,206 @@
/* global Blob,URL */
import React from 'react';
import hterm from '../hterm';
import Component from '../component';
export default class Term extends Component {
constructor (props) {
super(props);
this.state = { scrollable: false };
this.onWheel = this.onWheel.bind(this);
this.onScrollEnter = this.onScrollEnter.bind(this);
this.onScrollLeave = this.onScrollLeave.bind(this);
props.ref_(this);
}
componentDidMount () {
const { props } = this;
this.term = new hterm.Terminal();
// the first term that's created has unknown size
// subsequent new tabs have size
if (props.cols && props.rows) {
this.term.realizeSize_(props.cols, props.rows);
}
this.term.prefs_.set('font-family', props.fontFamily);
this.term.prefs_.set('font-size', props.fontSize);
this.term.prefs_.set('cursor-color', props.cursorColor);
this.term.prefs_.set('enable-clipboard-notice', false);
this.term.prefs_.set('foreground-color', props.foregroundColor);
this.term.prefs_.set('background-color', props.backgroundColor);
this.term.prefs_.set('color-palette-overrides', props.colors);
this.term.prefs_.set('user-css', this.getStylesheet(props.customCSS));
this.term.prefs_.set('scrollbar-visible', false);
this.term.onTerminalReady = () => {
const io = this.term.io.push();
io.onVTKeystroke = io.sendString = props.onData;
io.onTerminalResize = (cols, rows) => {
if (cols !== this.props.cols && rows !== this.props.rows) {
props.onResize(cols, rows);
}
};
};
this.term.decorate(this.refs.term);
this.term.installKeyboard();
if (this.props.onTerminal) this.props.onTerminal(this.term);
const iframeWindow = this.getTermDocument().defaultView;
iframeWindow.addEventListener('wheel', this.onWheel);
}
onWheel () {
this.term.prefs_.set('scrollbar-visible', true);
clearTimeout(this.scrollbarsHideTimer);
if (!this.scrollMouseEnter) {
this.scrollbarsHideTimer = setTimeout(() => {
this.term.prefs_.set('scrollbar-visible', false);
}, 1000);
}
}
onScrollEnter () {
clearTimeout(this.scrollbarsHideTimer);
this.term.prefs_.set('scrollbar-visible', true);
this.scrollMouseEnter = true;
}
onScrollLeave () {
this.term.prefs_.set('scrollbar-visible', false);
this.scrollMouseEnter = false;
}
write (data) {
this.term.io.print(data);
}
focus () {
this.term.focus();
}
clear () {
this.term.clearPreserveCursorRow();
}
getTermDocument () {
return this.term.document_;
}
getStylesheet (css) {
const blob = new Blob([`
.cursor-node[focus="false"] {
border-width: 1px !important;
}
${css}
`]);
return URL.createObjectURL(blob, { type: 'text/css' });
}
componentWillReceiveProps (nextProps) {
if (this.props.url !== nextProps.url) {
// when the url prop changes, we make sure
// the terminal starts or stops ignoring
// key input so that it doesn't conflict
// with the <webview>
if (nextProps.url) {
const io = this.term.io.push();
io.onVTKeystroke = io.sendString = (str) => {
if (1 === str.length && 3 === str.charCodeAt(0) /* Ctrl + C */) {
this.props.onURLAbort();
}
};
} else {
this.term.io.pop();
}
}
if (!this.props.cleared && nextProps.cleared) {
this.clear();
}
if (this.props.fontSize !== nextProps.fontSize) {
this.term.prefs_.set('font-size', nextProps.fontSize);
}
if (this.props.foregroundColor !== nextProps.foregroundColor) {
this.term.prefs_.set('foreground-color', nextProps.foregroundColor);
}
if (this.props.backgroundColor !== nextProps.backgroundColor) {
this.term.prefs_.set('background-color', nextProps.backgroundColor);
}
if (this.props.fontFamily !== nextProps.fontFamily) {
this.term.prefs_.set('font-family', nextProps.fontFamily);
}
if (this.props.cursorColor !== nextProps.cursorColor) {
this.term.prefs_.set('cursor-color', nextProps.cursorColor);
}
if (this.props.colors !== nextProps.colors) {
this.term.prefs_.set('color-palette-overrides', nextProps.colors);
}
if (this.props.customCSS !== nextProps.customCSS) {
this.term.prefs_.set('user-css', this.getStylesheet(nextProps.customCSS));
}
}
componentWillUnmount () {
const iframeWindow = this.getTermDocument().defaultView;
iframeWindow.addEventListener('wheel', this.onWheel);
clearTimeout(this.scrollbarsHideTimer);
this.props.ref_(null);
}
template (css) {
return <div className={ css('fit') }>
{ this.props.customChildrenBefore }
<div ref='term' className={ css('fit', 'term') } />
{ this.props.url
? <webview
src={this.props.url}
style={{
background: '#000',
position: 'absolute',
top: 0,
left: 0,
display: 'inline-flex',
width: '100%',
height: '100%'
}}></webview>
: null
}
<div
className={ css('scrollbarShim') }
onMouseEnter={ this.onScrollEnter }
onMouseLeave={ this.onScrollLeave } />
{ this.props.customChildren }
</div>;
}
styles () {
return {
fit: {
width: '100%',
height: '100%'
},
term: {
position: 'relative'
},
scrollbarShim: {
position: 'fixed',
right: 0,
width: '50px',
top: 0,
bottom: 0
}
};
}
}

182
app/lib/components/terms.js Normal file
View file

@ -0,0 +1,182 @@
import React from 'react';
import Term_ from './term';
import Component from '../component';
import { shouldComponentUpdate } from 'react-addons-pure-render-mixin';
import { last } from '../utils/array';
import { decorate, getTermProps } from '../utils/plugins';
const Term = decorate(Term_, 'Term');
export default class Terms extends Component {
constructor (props, context) {
super(props, context);
this.terms = {};
this.bound = new WeakMap();
this.onRef = this.onRef.bind(this);
props.ref_(this);
}
componentWillReceiveProps (next) {
const { write } = next;
if (write && this.props.write !== write) {
this.getTermByUid(write.uid).write(write.data);
}
// if we just rendered, we consider the first tab active
// why is this decided here? because what session becomes
// active is a *view* and *layout* concern. for example,
// if a split is closed (and we had split), the next active
// session after the close would be the one next to it
// *in the view*, not necessarily the model datastructure
if (next.sessions && next.sessions.length) {
if (!this.props.activeSession && next.sessions.length) {
this.props.onActive(next.sessions[0].uid);
} else if (this.props.sessions.length !== next.sessions.length) {
if (next.sessions.length > this.props.sessions.length) {
// if we are adding, we focused on the new one
this.props.onActive(last(next.sessions).uid);
return;
}
const newUids = uids(next.sessions);
const curActive = this.props.activeSession;
// if we closed an item that wasn't focused, nothing changes
if (~newUids.indexOf(curActive)) {
return;
}
const oldIndex = uids(this.props.sessions).indexOf(curActive);
if (newUids[oldIndex]) {
this.props.onActive(newUids[oldIndex]);
} else {
this.props.onActive(last(next.sessions).uid);
}
}
} else {
this.props.onActive(null);
}
}
shouldComponentUpdate (nextProps, nextState) {
let nextProps_ = nextProps;
if (this.props.write || nextProps.write) {
// ignore `write` when performing the comparison
nextProps_ = Object.assign({}, nextProps, { write: null });
}
return shouldComponentUpdate.call(this, nextProps_);
}
onRef (uid, term) {
if (term) {
this.terms[uid] = term;
} else {
delete this.terms[uid];
}
}
getTermByUid (uid) {
return this.terms[uid];
}
getActiveTerm () {
return this.getTermByUid(this.props.activeSession);
}
bind (fn, thisObj, uid) {
if (!this.bound.has(fn)) {
this.bound.set(fn, {});
}
const map = this.bound.get(fn);
if (!map[uid]) {
map[uid] = fn.bind(thisObj, uid);
}
return map[uid];
}
getTermProps (uid) {
return getTermProps(uid, this.props);
}
onTerminal (uid, term) {
this.terms[uid] = term;
}
componentWillUnmount () {
this.props.ref_(null);
}
template (css) {
return <div
style={{ padding: this.props.padding }}
className={ css('terms') }>
{ this.props.customChildrenBefore }
{
this.props.sessions.map((session) => {
const uid = session.uid;
const isActive = uid === this.props.activeSession;
const props = getTermProps(uid, this.props, {
cols: this.props.cols,
rows: this.props.rows,
customCSS: this.props.customCSS,
fontSize: this.props.fontSize,
cursorColor: this.props.cursorColor,
fontFamily: this.props.fontFamily,
foregroundColor: this.props.foregroundColor,
backgroundColor: this.props.backgroundColor,
colors: this.props.colors,
url: session.url,
cleared: session.cleared,
onResize: this.bind(this.props.onResize, null, uid),
onTitle: this.bind(this.props.onTitle, null, uid),
onData: this.bind(this.props.onData, null, uid),
onURLAbort: this.bind(this.props.onURLAbort, null, uid)
});
return <div
key={`d${uid}`}
className={css('term', isActive && 'termActive')}>
<Term
key={uid}
ref_={this.bind(this.onRef, this, uid)}
{...props} />
</div>;
})
}
{ this.props.customChildren }
</div>;
}
styles () {
return {
terms: {
position: 'absolute',
marginTop: '34px',
top: 0,
right: 0,
left: 0,
bottom: 0,
color: '#fff'
},
term: {
display: 'none',
width: '100%',
height: '100%'
},
termActive: {
display: 'block'
}
};
}
}
// little memoized helper to compute a map of uids
function uids (sessions) {
if (!sessions._uids) {
sessions._uids = sessions.map((s) => s.uid);
}
return sessions._uids;
}

View file

@ -0,0 +1,2 @@
export const CONFIG_LOAD = 'CONFIG_LOAD';
export const CONFIG_RELOAD = 'CONFIG_RELOAD';

View file

@ -0,0 +1 @@
export const INIT = 'INIT';

View file

@ -0,0 +1 @@
export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS';

View file

@ -0,0 +1,15 @@
export const SESSION_ADD = 'SESSION_ADD';
export const SESSION_RESIZE = 'SESSION_RESIZE';
export const SESSION_REQUEST = 'SESSION_REQUEST';
export const SESSION_ADD_DATA = 'SESSION_ADD_DATA';
export const SESSION_PTY_DATA = 'SESSION_PTY_DATA';
export const SESSION_PTY_EXIT = 'SESSION_PTY_EXIT';
export const SESSION_USER_EXIT = 'SESSION_USER_EXIT';
export const SESSION_USER_EXIT_ACTIVE = 'SESSION_USER_EXIT_ACTIVE';
export const SESSION_SET_ACTIVE = 'SESSION_SET_ACTIVE';
export const SESSION_CLEAR_ACTIVE = 'SESSION_CLEAR_ACTIVE';
export const SESSION_USER_DATA = 'SESSION_USER_DATA';
export const SESSION_URL_SET = 'SESSION_URL_SET';
export const SESSION_URL_UNSET = 'SESSION_URL_UNSET';
export const SESSION_SET_XTERM_TITLE = 'SESSION_SET_XTERM_TITLE';
export const SESSION_SET_PROCESS_TITLE = 'SESSION_SET_PROCESS_TITLE';

View file

@ -0,0 +1,2 @@
export const CLOSE_TAB = 'CLOSE_TAB';
export const CHANGE_TAB = 'CHANGE_TAB';

8
app/lib/constants/ui.js Normal file
View file

@ -0,0 +1,8 @@
export const UI_FONT_SIZE_SET = 'UI_FONT_SIZE_SET';
export const UI_FONT_SIZE_INCR = 'UI_FONT_SIZE_INCR';
export const UI_FONT_SIZE_DECR = 'UI_FONT_SIZE_DECR';
export const UI_FONT_SIZE_RESET = 'UI_FONT_SIZE_RESET';
export const UI_MOVE_LEFT = 'UI_MOVE_LEFT';
export const UI_MOVE_RIGHT = 'UI_MOVE_RIGHT';
export const UI_MOVE_TO = 'UI_MOVE_TO';
export const UI_SHOW_PREFERENCES = 'UI_SHOW_PREFERENCES';

View file

@ -0,0 +1,2 @@
export const UPDATE_INSTALL = 'UPDATE_INSTALL';
export const UPDATE_AVAILABLE = 'UPDATE_AVAILABLE';

View file

@ -0,0 +1,48 @@
import Header from '../components/header';
import { closeTab, changeTab } from '../actions/header';
import { values } from '../utils/object';
import { createSelector } from 'reselect';
import { connect } from '../utils/plugins';
const isMac = /Mac/.test(navigator.userAgent);
const getSessions = (sessions) => sessions.sessions;
const getActiveUid = (sessions) => sessions.activeUid;
const getActivityMarkers = (sessions, ui) => ui.activityMarkers;
const getTabs = createSelector(
[getSessions, getActiveUid, getActivityMarkers],
(sessions, activeUid, activityMarkers) => values(sessions).map((s) => {
return {
uid: s.uid,
title: s.title,
isActive: s.uid === activeUid,
hasActivity: activityMarkers[s.uid]
};
})
);
const HeaderContainer = connect(
(state) => {
return {
// active is an index
isMac,
tabs: getTabs(state.sessions, state.ui),
activeMarkers: state.ui.activityMarkers,
borderColor: state.ui.borderColor,
backgroundColor: state.ui.backgroundColor
};
},
(dispatch) => {
return {
onCloseTab: (i) => {
dispatch(closeTab(i));
},
onChangeTab: (i) => {
dispatch(changeTab(i));
}
};
}
)(Header, 'Header');
export default HeaderContainer;

View file

@ -0,0 +1,137 @@
import React from 'react';
import HeaderContainer from './header';
import TermsContainer from './terms';
import NotificationsContainer from './notifications';
import Component from '../component';
import Mousetrap from 'mousetrap';
import overrideStyles from '../utils/override-style';
import { moveTo, moveLeft, moveRight } from '../actions/ui';
import { connect } from '../utils/plugins';
const isMac = /Mac/.test(navigator.userAgent);
class HyperTerm extends Component {
constructor (props) {
super(props);
this.focusActive = this.focusActive.bind(this);
document.body.style.backgroundColor = props.backgroundColor;
this.onTermsRef = this.onTermsRef.bind(this);
}
componentWillReceiveProps (next) {
if (this.props.backgroundColor !== next.backgroundColor) {
document.body.style.backgroundColor = next.backgroundColor;
}
}
focusActive () {
const term = this.terms.getActiveTerm();
if (term) term.focus();
}
attachKeyListeners () {
const { moveTo, moveLeft, moveRight } = this.props;
const term = this.terms.getActiveTerm();
if (!term) return;
const document = term.getTermDocument();
const keys = new Mousetrap(document);
keys.bind('command+1', moveTo.bind(this, 0));
keys.bind('command+2', moveTo.bind(this, 1));
keys.bind('command+3', moveTo.bind(this, 2));
keys.bind('command+4', moveTo.bind(this, 3));
keys.bind('command+5', moveTo.bind(this, 4));
keys.bind('command+6', moveTo.bind(this, 5));
keys.bind('command+7', moveTo.bind(this, 6));
keys.bind('command+8', moveTo.bind(this, 7));
keys.bind('command+9', moveTo.bind(this, 8));
keys.bind('command+shift+left', moveLeft);
keys.bind('command+shift+right', moveRight);
keys.bind('command+shift+[', moveLeft);
keys.bind('command+shift+]', moveRight);
keys.bind('command+alt+left', moveLeft);
keys.bind('command+alt+right', moveRight);
this.keys = keys;
}
onTermsRef (terms) {
this.terms = terms;
}
componentDidUpdate (prev) {
if (prev.activeSession !== this.props.activeSession) {
if (this.keys) this.keys.reset();
this.focusActive();
this.attachKeyListeners();
}
}
componentWillUnmount () {
if (this.keys) this.keys.reset();
document.body.style.backgroundColor = 'inherit';
}
template (css) {
const { isMac, customCSS, borderColor } = this.props;
return <div onClick={ this.focusActive }>
<div
ref={ overrideStyles({ borderColor }) }
className={ css('main', isMac && 'mainRounded') }>
<HeaderContainer />
<TermsContainer ref_={this.onTermsRef} />
</div>
<NotificationsContainer />
<style dangerouslySetInnerHTML={{ __html: customCSS }} />
{ this.props.customChildren }
</div>;
}
styles () {
return {
main: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
// can be overridden by inline style above
border: '1px solid #333'
},
mainRounded: {
borderRadius: '5px'
}
};
}
}
const HyperTermContainer = connect(
(state) => {
return {
isMac,
customCSS: state.ui.css,
borderColor: state.ui.borderColor,
activeSession: state.sessions.activeUid,
backgroundColor: state.ui.backgroundColor
};
},
(dispatch) => {
return {
moveTo: (i) => {
dispatch(moveTo(i));
},
moveLeft: () => {
dispatch(moveLeft());
},
moveRight: () => {
dispatch(moveRight());
}
};
},
null,
{ withRef: true }
)(HyperTerm, 'HyperTerm');
export default HyperTermContainer;

View file

@ -0,0 +1,61 @@
import Notifications from '../components/notifications';
import { installUpdate } from '../actions/updater';
import { connect } from '../utils/plugins';
import { dismissNotification } from '../actions/notifications';
const NotificationsContainer = connect(
(state) => {
const { ui } = state;
const { notifications } = ui;
const state_ = {};
if (notifications.font) {
const fontSize = ui.fontSizeOverride || ui.fontSize;
Object.assign(state_, {
fontShowing: true,
fontSize,
fontText: `${fontSize}px`
});
}
if (notifications.resize) {
const cols = ui.cols;
const rows = ui.rows;
Object.assign(state_, {
resizeShowing: true,
cols,
rows
});
}
if (notifications.updates) {
Object.assign(state_, {
updateShowing: true,
updateVersion: ui.updateVersion,
updateNote: ui.updateNotes.split('\n')[0]
});
}
return state_;
},
(dispatch) => {
return {
onDismissFont: () => {
dispatch(dismissNotification('font'));
},
onDismissResize: () => {
dispatch(dismissNotification('resize'));
},
onDismissUpdate: () => {
dispatch(dismissNotification('updates'));
},
onUpdateInstall: () => {
dispatch(installUpdate());
}
};
}
)(Notifications, 'Notifications');
export default NotificationsContainer;

View file

@ -0,0 +1,61 @@
import Terms from '../components/terms';
import { values } from '../utils/object';
import { connect } from '../utils/plugins';
import {
resizeSession,
sendSessionData,
exitSessionBrowser,
setSessionXtermTitle,
setActiveSession
} from '../actions/sessions';
const TermsContainer = connect(
(state) => {
const sessions = state.sessions.sessions;
return {
cols: state.ui.cols,
rows: state.ui.rows,
sessions: values(sessions),
activeSession: state.sessions.activeUid,
customCSS: state.ui.termCSS,
write: state.sessions.write,
fontSize: state.ui.fontSizeOverride
? state.ui.fontSizeOverride
: state.ui.fontSize,
fontFamily: state.ui.fontFamily,
padding: state.ui.padding,
cursorColor: state.ui.cursorColor,
borderColor: state.ui.borderColor,
colors: state.ui.colors,
foregroundColor: state.ui.foregroundColor,
backgroundColor: state.ui.backgroundColor
};
},
(dispatch) => {
return {
onData (uid, data) {
dispatch(sendSessionData(uid, data));
},
onTitle (uid, title) {
dispatch(setSessionXtermTitle(uid, title));
},
onResize (uid, cols, rows) {
dispatch(resizeSession(uid, cols, rows));
},
onURLAbort (uid) {
dispatch(exitSessionBrowser(uid));
},
onActive (uid) {
dispatch(setActiveSession(uid));
}
};
},
null,
{ withRef: true }
)(Terms, 'Terms');
export default TermsContainer;

90
app/lib/hterm.js Normal file
View file

@ -0,0 +1,90 @@
import { hterm, lib } from 'hterm-umdjs';
hterm.defaultStorage = new lib.Storage.Memory();
// override double click behavior to copy
const oldMouse = hterm.Terminal.prototype.onMouse_;
hterm.Terminal.prototype.onMouse_ = function (e) {
if ('dblclick' === e.type) {
console.log('[hyperterm+hterm] ignore double click');
return;
}
return oldMouse.call(this, e);
};
// there's no option to turn off the size overlay
hterm.Terminal.prototype.overlaySize = function () {};
// fixing a bug in hterm where a double click triggers
// a non-collapsed selection whose text is '', and results
// in an infinite copy loop
hterm.Terminal.prototype.copySelectionToClipboard = function () {
var text = this.getSelectionText();
if (text != null && text !== '') {
this.copyStringToClipboard(text);
}
};
// passthrough all the commands that are meant to control
// hyperterm and not the terminal itself
const oldKeyDown = hterm.Keyboard.prototype.onKeyDown_;
hterm.Keyboard.prototype.onKeyDown_ = function (e) {
if (e.metaKey) {
return;
}
return oldKeyDown.call(this, e);
};
const oldKeyPress = hterm.Keyboard.prototype.onKeyPress_;
hterm.Keyboard.prototype.onKeyPress_ = function (e) {
if (e.metaKey) {
return;
}
return oldKeyPress.call(this, e);
};
// we re-implement `wipeContents` to preserve the line
// and cursor position that the client is in.
// otherwise the user ends up with a completely clear
// screen which is really strange
hterm.Terminal.prototype.clearPreserveCursorRow = function () {
this.scrollbackRows_.length = 0;
this.scrollPort_.resetCache();
[this.primaryScreen_, this.alternateScreen_].forEach((screen) => {
const bottom = screen.getHeight();
if (bottom > 0) {
this.renumberRows_(0, bottom);
const x = screen.cursorPosition.column;
const y = screen.cursorPosition.row;
if (x === 0) {
// Empty screen, nothing to do.
return;
}
// here we move the row that the user was focused on
// to the top of the screen
this.moveRows_(y, 1, 0);
for (let i = 1; i < bottom; i++) {
screen.setCursorPosition(i, 0);
screen.clearCursorRow();
}
// we restore the cursor position
screen.setCursorPosition(0, x);
}
});
this.syncCursorPosition_();
this.scrollPort_.invalidate();
// this will avoid a bug where the `wipeContents`
// hterm API doens't send the scroll to the top
this.scrollPort_.redraw_();
};
export default hterm;
export { lib };

110
app/lib/index.js Normal file
View file

@ -0,0 +1,110 @@
import rpc from './rpc';
import React from 'react';
import { render } from 'react-dom';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { init } from './actions/index';
import effects from './utils/effects';
import * as config from './utils/config';
import rootReducer from './reducers/index';
import * as plugins from './utils/plugins';
import * as uiActions from './actions/ui';
import forceUpdate from 'react-deep-force-update';
import * as updaterActions from './actions/updater';
import * as sessionActions from './actions/sessions';
import { createStore, applyMiddleware } from 'redux';
import HyperTermContainer from './containers/hyperterm';
import { loadConfig, reloadConfig } from './actions/config';
const store_ = createStore(
rootReducer,
applyMiddleware(
thunk,
plugins.middleware,
thunk,
effects
)
);
window.__defineGetter__('store', () => store_);
window.__defineGetter__('rpc', () => rpc);
// initialize config
store_.dispatch(loadConfig(config.getConfig()));
config.subscribe(() => {
store_.dispatch(reloadConfig(config.getConfig()));
});
// initialize communication with main electron process
// and subscribe to all user intents for example from menues
rpc.on('ready', () => {
store_.dispatch(init());
});
rpc.on('session add', ({ uid }) => {
store_.dispatch(sessionActions.addSession(uid));
});
rpc.on('session data', ({ uid, data }) => {
store_.dispatch(sessionActions.addSessionData(uid, data));
});
rpc.on('session title', ({ uid, title }) => {
store_.dispatch(sessionActions.setSessionProcessTitle(uid, title));
});
rpc.on('session exit', ({ uid }) => {
store_.dispatch(sessionActions.sessionExit(uid));
});
rpc.on('session add req', () => {
store_.dispatch(sessionActions.requestSession());
});
rpc.on('session close req', () => {
store_.dispatch(sessionActions.userExitActiveSession());
});
rpc.on('session clear req', () => {
store_.dispatch(sessionActions.clearActiveSession());
});
rpc.on('reset fontSize req', () => {
store_.dispatch(uiActions.resetFontSize());
});
rpc.on('increase fontSize req', () => {
store_.dispatch(uiActions.increaseFontSize());
});
rpc.on('decrease fontSize req', () => {
store_.dispatch(uiActions.decreaseFontSize());
});
rpc.on('move left req', () => {
store_.dispatch(uiActions.moveLeft());
});
rpc.on('move right req', () => {
store_.dispatch(uiActions.moveRight());
});
rpc.on('preferences', () => {
store_.dispatch(uiActions.showPreferences());
});
rpc.on('update available', ({ releaseName, releaseNotes }) => {
store_.dispatch(updaterActions.updateAvailable(releaseName, releaseNotes));
});
const app = render(
<Provider store={ store_ }>
<HyperTermContainer />
</Provider>,
document.getElementById('mount')
);
rpc.on('reload', () => {
plugins.reload();
forceUpdate(app);
});

View file

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

View file

@ -0,0 +1,105 @@
import Immutable from 'seamless-immutable';
import { decorateSessionsReducer } from '../utils/plugins';
import {
SESSION_ADD,
SESSION_PTY_EXIT,
SESSION_USER_EXIT,
SESSION_PTY_DATA,
SESSION_SET_ACTIVE,
SESSION_CLEAR_ACTIVE,
SESSION_URL_SET,
SESSION_URL_UNSET,
SESSION_SET_XTERM_TITLE,
SESSION_SET_PROCESS_TITLE
} from '../constants/sessions';
const initialState = Immutable({
sessions: {},
write: null,
activeUid: null
});
function Session (obj) {
return Immutable({
uid: '',
title: '',
write: null,
url: null,
cleared: false
}).merge(obj);
}
function Write (obj) {
return Immutable({
uid: '',
data: ''
}).merge(obj);
}
const reducer = (state = initialState, action) => {
// prune the last write to the terminal
if (state.write) {
state = state.set('write', null);
}
switch (action.type) {
case SESSION_ADD:
return state.setIn(['sessions', action.uid], Session({ uid: action.uid }));
case SESSION_URL_SET:
return state.setIn(['sessions', action.uid, 'url'], action.url);
case SESSION_URL_UNSET:
return state.setIn(['sessions', action.uid, 'url'], null);
case SESSION_SET_ACTIVE:
return state.set('activeUid', action.uid);
case SESSION_CLEAR_ACTIVE:
return state.merge({
sessions: {
[state.activeUid]: {
cleared: true
}
}
}, { deep: true });
case SESSION_PTY_DATA:
return state.merge({
write: Write(action),
sessions: {
[action.uid]: {
cleared: false
}
}
}, { deep: true });
case SESSION_PTY_EXIT:
if (state.sessions[action.uid]) {
return deleteSession(state, action.uid);
} else {
console.log('ignore pty exit: session removed by user');
return state;
}
case SESSION_USER_EXIT:
return deleteSession(state, action.uid);
case SESSION_SET_XTERM_TITLE:
case SESSION_SET_PROCESS_TITLE:
return state.setIn(['sessions', action.uid, 'title'], action.title);
default:
return state;
}
};
export default decorateSessionsReducer(reducer);
function deleteSession (state, uid) {
return state.updateIn(['sessions'], (sessions) => {
const sessions_ = sessions.asMutable();
delete sessions_[uid];
return sessions_;
});
}

231
app/lib/reducers/ui.js Normal file
View file

@ -0,0 +1,231 @@
import Immutable from 'seamless-immutable';
import { decorateUIReducer } from '../utils/plugins';
import { CONFIG_LOAD, CONFIG_RELOAD } from '../constants/config';
import { UI_FONT_SIZE_SET, UI_FONT_SIZE_RESET } from '../constants/ui';
import { NOTIFICATION_DISMISS } from '../constants/notifications';
import {
SESSION_ADD,
SESSION_RESIZE,
SESSION_PTY_DATA,
SESSION_PTY_EXIT,
SESSION_SET_ACTIVE
} from '../constants/sessions';
import { UPDATE_AVAILABLE } from '../constants/updater';
// TODO: populate `config-default.js` from this :)
const initial = Immutable({
cols: null,
rows: null,
activeUid: null,
cursorColor: '#F81CE5',
borderColor: '#333',
fontSize: 12,
padding: '12px 14px',
fontFamily: 'Menlo, "DejaVu Sans Mono", "Lucida Console", monospace',
fontSizeOverride: null,
css: '',
termCSS: '',
openAt: {},
resizeAt: 0,
colors: [
'#000000',
'#ff0000',
'#33ff00',
'#ffff00',
'#0066ff',
'#cc00ff',
'#00ffff',
'#d0d0d0',
'#808080',
'#ff0000',
'#33ff00',
'#ffff00',
'#0066ff',
'#cc00ff',
'#00ffff',
'#ffffff'
],
activityMarkers: {},
notifications: {
font: false,
resize: false,
updates: false
},
foregroundColor: '#fff',
backgroundColor: '#000',
updateVersion: null,
updateNotes: null
});
const reducer = (state = initial, action) => {
let state_ = state;
switch (action.type) {
case CONFIG_LOAD:
case CONFIG_RELOAD:
const { config } = action;
state_ = state
// we unset the user font size override if the
// font size changed from the config
.merge((() => {
const ret = {};
if (state.fontSizeOverride && config.fontSize !== state.fontSize) {
ret.fontSizeOverride = null;
}
if (null != config.fontSize) {
ret.fontSize = config.fontSize;
}
if (null != config.fontFamily) {
ret.fontFamily = config.fontFamily;
}
if (null != config.cursorColor) {
ret.cursorColor = config.cursorColor;
}
if (null != config.borderColor) {
ret.borderColor = config.borderColor;
}
if (null != config.padding) {
ret.padding = config.padding;
}
if (null != config.foregroundColor) {
ret.foregroundColor = config.foregroundColor;
}
if (null != config.backgroundColor) {
ret.backgroundColor = config.backgroundColor;
}
if (null != config.css) {
ret.css = config.css;
}
if (null != config.termCSS) {
ret.termCSS = config.termCSS;
}
if (null != config.colors) {
if (state.colors.toString() !== config.colors.toString()) {
ret.colors = config.colors;
}
}
return ret;
})());
break;
case SESSION_ADD:
state_ = state.merge({
openAt: {
[action.uid]: Date.now()
}
}, { deep: true });
break;
case SESSION_RESIZE:
state_ = state.merge({
rows: action.rows,
cols: action.cols,
resizeAt: Date.now()
});
break;
case SESSION_PTY_EXIT:
state_ = state
.updateIn(['openAt'], (times) => {
const times_ = times.asMutable();
delete times_[action.uid];
return times_;
})
.updateIn(['activityMarkers'], (markers) => {
const markers_ = markers.asMutable();
delete markers_[action.uid];
return markers_;
});
break;
case SESSION_SET_ACTIVE:
state_ = state.merge({
activeUid: action.uid,
activityMarkers: {
[action.uid]: false
}
}, { deep: true });
break;
case SESSION_PTY_DATA:
// ignore activity markers for current tab
if (action.uid === state.activeUid) break;
// current time for comparisons
let now = Date.now();
// if first data events after open, ignore
if (now - state.openAt[action.uid] < 1000) break;
// we ignore activity markers that are within
// proximity of a resize event, since we
// expect to get data packets from the resize
// of the ptys as a result
if (!state.resizeAt || now - state.resizeAt > 1000) {
state_ = state.merge({
activityMarkers: {
[action.uid]: true
}
}, { deep: true });
}
break;
case UI_FONT_SIZE_SET:
state_ = state.set('fontSizeOverride', action.value);
break;
case UI_FONT_SIZE_RESET:
state_ = state.set('fontSizeOverride', null);
break;
case NOTIFICATION_DISMISS:
state_ = state.merge({
notifications: {
[action.id]: false
}
}, { deep: true });
break;
case UPDATE_AVAILABLE:
state_ = state.merge({
updateVersion: action.version,
updateNotes: action.notes || ''
});
break;
}
// we check that if any of the font size values changed
// we show a notification
if (CONFIG_LOAD !== action.type) {
if (state_.fontSize !== state.fontSize ||
state_.fontSizeOverride !== state.fontSizeOverride) {
state_ = state_.merge({ notifications: { font: true } }, { deep: true });
}
}
if (null != state.cols && null != state.rows &&
(state.rows !== state_.rows ||
state.cols !== state_.cols)) {
state_ = state_.merge({ notifications: { resize: true } }, { deep: true });
}
if (state.updateVersion !== state_.updateVersion) {
state_ = state_.merge({ notifications: { updates: true } }, { deep: true });
}
return state_;
};
export default decorateUIReducer(reducer);

View file

@ -1,5 +1,3 @@
import RPC from './utils/rpc';
const rpc = new RPC();
export default rpc;

3
app/lib/utils/array.js Normal file
View file

@ -0,0 +1,3 @@
export function last (arr) {
return arr[arr.length - 1];
}

13
app/lib/utils/config.js Normal file
View file

@ -0,0 +1,13 @@
import { ipcRenderer, remote } from 'electron';
const config = remote.require('./config');
export function getConfig () {
return config.getConfig();
}
export function subscribe (fn) {
ipcRenderer.on('config change', fn);
return () => {
ipcRenderer.removeListener('config change', fn);
};
}

15
app/lib/utils/effects.js vendored Normal file
View file

@ -0,0 +1,15 @@
// simple redux middleware that executes
// the `effect` field if provided in an action
// since this is preceded by the `plugins`
// middleware, it allows authors to interrumpt,
// defer or add to existing side effects at will
// as the result of an action being triggered
export default (store) => (next) => (action) => {
const ret = next(action);
if (action.effect) {
action.effect();
delete action.effect;
}
return ret;
};

6
app/lib/utils/notify.js Normal file
View file

@ -0,0 +1,6 @@
/* global Notification */
/* eslint no-new:0 */
export default function notify (title, body) {
console.log(`[Notification] ${title}: ${body}`);
new Notification(title, { body });
}

17
app/lib/utils/object.js Normal file
View file

@ -0,0 +1,17 @@
import vals from 'object-values';
const valsCache = new WeakMap();
export function values (imm) {
if (!valsCache.has(imm)) {
valsCache.set(imm, vals(imm));
}
return valsCache.get(imm);
}
const keysCache = new WeakMap();
export function keys (imm) {
if (!keysCache.has(imm)) {
keysCache.set(imm, Object.keys(imm));
}
return keysCache.get(imm);
}

View file

@ -0,0 +1,31 @@
// hack to configure important style attributes
// to work around this madness:
// https://github.com/Khan/aphrodite/pull/41
export default function overrideStyle (obj) {
if (obj) {
return function (ref) {
if (ref) {
for (const key in obj) {
if (null != obj[key]) {
const val = 'number' === typeof obj[key]
? `${obj[key]}px`
: String(obj[key]);
const prop = toProp(key);
ref.style.setProperty(prop, val, 'important');
}
}
}
};
} else {
return null;
}
}
// converts camelCase to camel-case
function toProp (key) {
return key.replace(
/[a-z]([A-Z])/,
(a, b) => a.substr(0, a.length - 1) + '-' + b.toLowerCase()
);
}

382
app/lib/utils/plugins.js Normal file
View file

@ -0,0 +1,382 @@
import { ipcRenderer, remote } from 'electron';
import { connect as reduxConnect } from 'react-redux';
// we expose these two deps to component decorators
import React from 'react';
import Notification from '../components/notification';
import notify from './notify';
// remote interface to `../plugins`
let plugins = remote.require('./plugins');
// `require`d modules
let modules;
// cache of decorated components
let decorated = {};
// varios caches extracted of the plugin methods
let connectors;
let middlewares;
let uiReducers;
let sessionsReducers;
let tabPropsDecorators;
let tabsPropsDecorators;
let termPropsDecorators;
// the fs locations where usr plugins are stored
const { path, localPath } = plugins.getBasePaths();
const clearModulesCache = () => {
// trigger unload hooks
modules.forEach((mod) => {
if (mod.onRendererUnload) mod.onRendererUnload(window);
});
// clear require cache
for (const entry in window.require.cache) {
if (entry.indexOf(path) === 0 || entry.indexOf(localPath) === 0) {
// `require` is webpacks', `window.require`, electron's
delete window.require.cache[entry];
}
}
};
const getPluginName = (path) => window.require('path').basename(path);
const loadModules = () => {
console.log('(re)loading renderer plugins');
const paths = plugins.getPaths();
// initialize cache that we populate with extension methods
connectors = {
Terms: { state: [], dispatch: [] },
Header: { state: [], dispatch: [] },
HyperTerm: { state: [], dispatch: [] },
Notifications: { state: [], dispatch: [] }
};
uiReducers = [];
middlewares = [];
sessionsReducers = [];
tabPropsDecorators = [];
tabsPropsDecorators = [];
termPropsDecorators = [];
modules = paths.plugins.concat(paths.localPlugins)
.map((path) => {
let mod;
const pluginName = getPluginName(path);
// window.require allows us to ensure this doens't get
// in the way of our build
try {
mod = window.require(path);
} catch (err) {
console.error(err.stack);
notify('Plugin load error', `"${pluginName}" failed to load in the renderer process. Check Developer Tools for details.`);
return;
}
for (const i in mod) {
mod[i]._pluginName = pluginName;
}
if (mod.middleware) {
middlewares.push(mod.middleware);
}
if (mod.reduceUI) {
uiReducers.push(mod.reduceUI);
}
if (mod.reduceSessions) {
sessionsReducers.push(mod.reduceSessions);
}
if (mod.mapTermsState) {
connectors.Terms.state.push(mod.mapTermsState);
}
if (mod.mapTermsDispatch) {
connectors.Terms.dispatch.push(mod.mapTermsState);
}
if (mod.mapHeaderState) {
connectors.Header.state.push(mod.mapHeaderState);
}
if (mod.mapHeaderDispatch) {
connectors.Header.dispatch.push(mod.mapHeaderDispatch);
}
if (mod.mapHyperTermState) {
connectors.HyperTerm.state.push(mod.mapHyperTermState);
}
if (mod.mapHyperTermDispatch) {
connectors.HyperTerm.dispatch.push(mod.mapHyperTermDispatch);
}
if (mod.mapNotificationsState) {
connectors.Notifications.state.push(mod.mapNotificationsState);
}
if (mod.mapNotificationsDispatch) {
connectors.Notifications.dispatch.push(mod.mapNotificationsDispatch);
}
if (mod.getTermProps) {
termPropsDecorators.push(mod.getTermProps);
}
if (mod.getTabProps) {
tabPropsDecorators.push(mod.getTabProps);
}
if (mod.getTabsProps) {
tabsPropsDecorators.push(mod.getTabsProps);
}
if (mod.onRendererWindow) {
mod.onRendererWindow(window);
}
return mod;
})
.filter((mod) => !!mod);
};
// load modules for initial decoration
loadModules();
export function reload () {
clearModulesCache();
loadModules();
// trigger re-decoration when components
// get re-rendered
decorated = {};
}
export function getTermProps (uid, parentProps, props) {
let props_;
termPropsDecorators.forEach((fn) => {
let ret_;
if (!props_) props_ = Object.assign({}, props);
try {
ret_ = fn(uid, parentProps, props_);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`getTermProps\`. Check Developer Tools for details.`);
return;
}
if (!ret_ || 'object' !== typeof ret_) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`getTermProps\` (object expected).`);
return;
}
props = ret_;
});
return props_ || props;
}
export function getTabsProps (parentProps, props) {
let props_;
tabsPropsDecorators.forEach((fn) => {
let ret_;
if (!props_) props_ = Object.assign({}, props);
try {
ret_ = fn(parentProps, props_);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`getTabsProps\`. Check Developer Tools for details.`);
return;
}
if (!ret_ || 'object' !== typeof ret_) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`getTabsProps\` (object expected).`);
return;
}
props_ = ret_;
});
return props_ || props;
}
export function getTabProps (tab, parentProps, props) {
let props_;
tabPropsDecorators.forEach((fn) => {
let ret_;
if (!props_) props_ = Object.assign({}, props);
try {
ret_ = fn(tab, parentProps, props_);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`getTabProps\`. Check Developer Tools for details.`);
return;
}
if (!ret_ || 'object' !== typeof ret_) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`getTabProps\` (object expected).`);
return;
}
props_ = ret_;
});
return props_ || props;
}
// connects + decorates a class
// plugins can override mapToState, dispatchToProps
// and the class gets deorated (proxied)
export function connect (stateFn, dispatchFn, c, d = {}) {
return function (Class, name) {
return reduxConnect(
function (state) {
let ret = stateFn(state);
connectors[name].state.forEach((fn) => {
let ret_;
try {
ret_ = fn(state, ret);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`map${name}State\`. Check Developer Tools for details.`);
return;
}
if (!ret_ || 'object' !== typeof ret_) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`map${name}State\` (object expected).`);
return;
}
ret = ret_;
});
return ret;
},
dispatchFn,
c,
d
)(decorate(Class, name));
};
}
export function decorateUIReducer (fn) {
return (state, action) => {
let state_ = fn(state, action);
uiReducers.forEach((pluginReducer) => {
let state__;
try {
state__ = pluginReducer(state_, action);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`reduceUI\`. Check Developer Tools for details.`);
return;
}
if (!state__ || 'object' !== typeof state__) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`reduceUI\`.`);
return;
}
state_ = state__;
});
return state_;
};
}
export function decorateSessionsReducer (fn) {
return (state, action) => {
let state_ = fn(state, action);
sessionsReducers.forEach((pluginReducer) => {
let state__;
try {
state__ = pluginReducer(state_, action);
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`reduceSessions\`. Check Developer Tools for details.`);
return;
}
if (!state__ || 'object' !== typeof state__) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`reduceSessions\`.`);
return;
}
state_ = state__;
});
return state_;
};
}
// redux middleware generator
export const middleware = (store) => (next) => (action) => {
const nextMiddleware = remaining => action => remaining.length
? remaining[0](store)(nextMiddleware(remaining.slice(1)))(action)
: next(action);
nextMiddleware(middlewares)(action);
};
function getDecorated (parent, name) {
if (!decorated[name]) {
let class_ = parent;
modules.forEach((mod) => {
const method = 'decorate' + name;
const fn = mod[method];
if (fn) {
let class__;
try {
class__ = fn(class_, { React, Notification, notify });
} catch (err) {
console.error(err.stack);
notify('Plugin error', `${fn._pluginName}: Error occurred in \`${method}\`. Check Developer Tools for details`);
return;
}
if (!class__ || 'function' !== typeof class__.prototype.render) {
notify('Plugin error', `${fn._pluginName}: Invalid return value of \`${method}\`. No \`render\` method found. Please return a \`React.Component\`.`);
return;
}
class_ = class__;
}
});
decorated[name] = class_;
}
return decorated[name];
}
// for each component, we return a higher-order component
// that wraps with the higher-order components
// exposed by plugins
export function decorate (Component, name) {
return class extends React.Component {
render () {
const Sub = getDecorated(Component, name);
return <Sub {...this.props} />;
}
};
}

58
app/lib/utils/rpc.js Normal file
View file

@ -0,0 +1,58 @@
export default class Client {
constructor () {
const electron = window.require('electron');
const EventEmitter = window.require('events');
this.emitter = new EventEmitter();
this.ipc = electron.ipcRenderer;
this.ipcListener = this.ipcListener.bind(this);
if (window.__rpcId) {
setTimeout(() => {
this.id = window.__rpcId;
this.ipc.on(this.id, this.ipcListener);
this.emitter.emit('ready');
}, 0);
} else {
this.ipc.on('init', (ev, uid) => {
// we cache so that if the object
// gets re-instantiated we don't
// wait for a `init` event
window.__rpcId = uid;
this.id = uid;
this.ipc.on(uid, this.ipcListener);
this.emitter.emit('ready');
});
}
}
ipcListener (event, { ch, data }) {
this.emitter.emit(ch, data);
}
on (ev, fn) {
this.emitter.on(ev, fn);
}
once (ev, fn) {
this.emitter.once(ev, fn);
}
emit (ev, data) {
if (!this.id) throw new Error('Not ready');
this.ipc.send(this.id, { ev, data });
}
removeListener (ev, fn) {
this.emitter.removeListener(ev, fn);
}
removeAllListeners () {
this.emitter.removeAllListeners();
}
destroy () {
this.removeAllListeners();
this.ipc.removeAllListeners();
}
}

View file

@ -0,0 +1,40 @@
import * as regex from './url-regex';
export default function isUrlCommand (data) {
let match = data.match(regex.bash);
let url;
if (match) {
url = match[5];
} else {
match = data.match(regex.zsh);
if (match) {
url = match[7];
} else {
match = data.match(regex.fish);
if (match) {
url = match[4];
}
}
}
if (url) {
// extract the domain portion from the url
const domain = url.split('/')[0];
if (regex.domain.test(domain)) {
return toURL(url);
}
}
}
function toURL (domain) {
if (/^https?:\/\//.test(domain)) {
return domain;
}
if ('//' === domain.substr(0, 2)) {
return domain;
}
return 'http://' + domain;
}

View file

@ -0,0 +1,4 @@
export const domain = /\b((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63}\b/;
export const bash = /(ba)?sh: ((https?:\/\/)|(\/\/))?(.*): ((command not found)|(No such file or directory))/;
export const zsh = /zsh: ((command not found)|(no such file or directory)): ((https?:\/\/)|(\/\/))?([^\n]+)/;
export const fish = /fish: Unknown command '((https?:\/\/)|(\/\/))?([^']+)'/;

View file

@ -1,84 +0,0 @@
// Packages
import {app, dialog, Menu} from 'electron';
import type {BrowserWindow} from 'electron';
// Utilities
import {execCommand} from '../commands';
import {getConfig} from '../config';
import {icon} from '../config/paths';
import {getDecoratedKeymaps} from '../plugins';
import {getRendererTypes} from '../utils/renderer-utils';
import darwinMenu from './menus/darwin';
import editMenu from './menus/edit';
import helpMenu from './menus/help';
import shellMenu from './menus/shell';
import toolsMenu from './menus/tools';
import viewMenu from './menus/view';
import windowMenu from './menus/window';
const appName = app.name;
const appVersion = app.getVersion();
let menu_: Menu;
export const createMenu = (
createWindow: (fn?: (win: BrowserWindow) => void, options?: Record<string, any>) => BrowserWindow,
getLoadedPluginVersions: () => {name: string; version: string}[]
) => {
const config = getConfig();
// We take only first shortcut in array for each command
const allCommandKeys = getDecoratedKeymaps();
const commandKeys = Object.keys(allCommandKeys).reduce((result: Record<string, string>, command) => {
result[command] = allCommandKeys[command][0];
return result;
}, {});
let updateChannel = 'stable';
if (config?.updateChannel && config.updateChannel === 'canary') {
updateChannel = 'canary';
}
const showAbout = () => {
const loadedPlugins = getLoadedPluginVersions();
const pluginList =
loadedPlugins.length === 0 ? 'none' : loadedPlugins.map((plugin) => `\n ${plugin.name} (${plugin.version})`);
const rendererCounts = Object.values(getRendererTypes()).reduce((acc: Record<string, number>, type) => {
acc[type] = acc[type] ? acc[type] + 1 : 1;
return acc;
}, {});
const renderers = Object.entries(rendererCounts)
.map(([type, count]) => type + (count > 1 ? ` (${count})` : ''))
.join(', ');
void dialog.showMessageBox({
title: `About ${appName}`,
message: `${appName} ${appVersion} (${updateChannel})`,
detail: `Renderers: ${renderers}\nPlugins: ${pluginList}\n\nCreated by Guillermo Rauch\nCopyright © 2022 Vercel, Inc.`,
buttons: [],
icon: icon as any
});
};
const menu = [
...(process.platform === 'darwin' ? [darwinMenu(commandKeys, execCommand, showAbout)] : []),
shellMenu(
commandKeys,
execCommand,
getConfig().profiles.map((p) => p.name)
),
editMenu(commandKeys, execCommand),
viewMenu(commandKeys, execCommand),
toolsMenu(commandKeys, execCommand),
windowMenu(commandKeys, execCommand),
helpMenu(commandKeys, showAbout)
];
return menu;
};
export const buildMenu = (template: Electron.MenuItemConstructorOptions[]): Electron.Menu => {
menu_ = Menu.buildFromTemplate(template);
return menu_;
};

View file

@ -1,59 +0,0 @@
// This menu label is overrided by OSX to be the appName
// The label is set to appName here so it matches actual behavior
import {app} from 'electron';
import type {BrowserWindow, MenuItemConstructorOptions} from 'electron';
const darwinMenu = (
commandKeys: Record<string, string>,
execCommand: (command: string, focusedWindow?: BrowserWindow) => void,
showAbout: () => void
): MenuItemConstructorOptions => {
return {
label: `${app.name}`,
submenu: [
{
label: 'About Hyper',
click() {
showAbout();
}
},
{
type: 'separator'
},
{
label: 'Preferences...',
accelerator: commandKeys['window:preferences'],
click() {
execCommand('window:preferences');
}
},
{
type: 'separator'
},
{
role: 'services',
submenu: []
},
{
type: 'separator'
},
{
role: 'hide'
},
{
role: 'hideOthers'
},
{
role: 'unhide'
},
{
type: 'separator'
},
{
role: 'quit'
}
]
};
};
export default darwinMenu;

View file

@ -1,151 +0,0 @@
import type {BrowserWindow, MenuItemConstructorOptions} from 'electron';
const editMenu = (
commandKeys: Record<string, string>,
execCommand: (command: string, focusedWindow?: BrowserWindow) => void
) => {
const submenu: MenuItemConstructorOptions[] = [
{
label: 'Undo',
accelerator: commandKeys['editor:undo'],
enabled: false
},
{
label: 'Redo',
accelerator: commandKeys['editor:redo'],
enabled: false
},
{
type: 'separator'
},
{
label: 'Cut',
accelerator: commandKeys['editor:cut'],
enabled: false
},
{
role: 'copy',
command: 'editor:copy',
accelerator: commandKeys['editor:copy'],
registerAccelerator: true
} as any,
{
role: 'paste',
accelerator: commandKeys['editor:paste'],
registerAccelerator: true
},
{
label: 'Select All',
accelerator: commandKeys['editor:selectAll'],
click(item, focusedWindow) {
execCommand('editor:selectAll', focusedWindow);
}
},
{
type: 'separator'
},
{
label: 'Move to...',
submenu: [
{
label: 'Previous word',
accelerator: commandKeys['editor:movePreviousWord'],
click(item, focusedWindow) {
execCommand('editor:movePreviousWord', focusedWindow);
}
},
{
label: 'Next word',
accelerator: commandKeys['editor:moveNextWord'],
click(item, focusedWindow) {
execCommand('editor:moveNextWord', focusedWindow);
}
},
{
label: 'Line beginning',
accelerator: commandKeys['editor:moveBeginningLine'],
click(item, focusedWindow) {
execCommand('editor:moveBeginningLine', focusedWindow);
}
},
{
label: 'Line end',
accelerator: commandKeys['editor:moveEndLine'],
click(item, focusedWindow) {
execCommand('editor:moveEndLine', focusedWindow);
}
}
]
},
{
label: 'Delete...',
submenu: [
{
label: 'Previous word',
accelerator: commandKeys['editor:deletePreviousWord'],
click(item, focusedWindow) {
execCommand('editor:deletePreviousWord', focusedWindow);
}
},
{
label: 'Next word',
accelerator: commandKeys['editor:deleteNextWord'],
click(item, focusedWindow) {
execCommand('editor:deleteNextWord', focusedWindow);
}
},
{
label: 'Line beginning',
accelerator: commandKeys['editor:deleteBeginningLine'],
click(item, focusedWindow) {
execCommand('editor:deleteBeginningLine', focusedWindow);
}
},
{
label: 'Line end',
accelerator: commandKeys['editor:deleteEndLine'],
click(item, focusedWindow) {
execCommand('editor:deleteEndLine', focusedWindow);
}
}
]
},
{
type: 'separator'
},
{
label: 'Clear Buffer',
accelerator: commandKeys['editor:clearBuffer'],
click(item, focusedWindow) {
execCommand('editor:clearBuffer', focusedWindow);
}
},
{
label: 'Search',
accelerator: commandKeys['editor:search'],
click(item, focusedWindow) {
execCommand('editor:search', focusedWindow);
}
}
];
if (process.platform !== 'darwin') {
submenu.push(
{type: 'separator'},
{
label: 'Preferences...',
accelerator: commandKeys['window:preferences'],
click() {
execCommand('window:preferences');
}
}
);
}
return {
label: 'Edit',
submenu
};
};
export default editMenu;

View file

@ -1,114 +0,0 @@
import {release} from 'os';
import {app, shell, dialog, clipboard} from 'electron';
import type {MenuItemConstructorOptions} from 'electron';
import {getConfig, getPlugins} from '../../config';
import {version} from '../../package.json';
const {arch, env, platform, versions} = process;
const helpMenu = (commands: Record<string, string>, showAbout: () => void): MenuItemConstructorOptions => {
const submenu: MenuItemConstructorOptions[] = [
{
label: `${app.name} Website`,
click() {
void shell.openExternal('https://hyper.is');
}
},
{
label: 'Report Issue',
click(menuItem, focusedWindow) {
const body = `<!--
Hi there! Thank you for discovering and submitting an issue.
Before you submit this; let's make sure of a few things.
Please make sure the following boxes are if they are correct.
If not, please try and fulfil these first.
-->
<!-- 👉 Checked checkbox should look like this: [x] -->
- [ ] Your Hyper.app version is **${version}**. Please verify you're using the [latest](https://github.com/vercel/hyper/releases/latest) Hyper.app version
- [ ] I have searched the [issues](https://github.com/vercel/hyper/issues) of this repo and believe that this is not a duplicate
---
- **Any relevant information from devtools?** _(CMD+OPTION+I on macOS, CTRL+SHIFT+I elsewhere)_:
<!-- 👉 Replace with info if applicable, or N/A -->
- **Is the issue reproducible in vanilla Hyper.app?**
<!-- 👉 Replace with info if applicable, or Is Vanilla. (Vanilla means Hyper.app without any add-ons or extras. Straight out of the box.) -->
## Issue
<!-- 👉 Now feel free to write your issue, but please be descriptive! Thanks again 🙌 ❤️ -->
---
<!-- hyper.json config -->
- **${app.name} version**: ${env.TERM_PROGRAM_VERSION} "${app.getVersion()}"
- **OS ARCH VERSION:** ${platform} ${arch} ${release()}
- **Electron:** ${versions.electron} **LANG:** ${env.LANG}
- **SHELL:** ${env.SHELL} **TERM:** ${env.TERM}
<details><summary><strong>hyper.json contents</strong></summary>
\`\`\`json
${JSON.stringify(getConfig(), null, 2)}
\`\`\`
</details>
<details><summary><strong>plugins</strong></summary>
\`\`\`json
${JSON.stringify(getPlugins(), null, 2)}
\`\`\`
</details>`;
const issueURL = `https://github.com/vercel/hyper/issues/new?body=${encodeURIComponent(body)}`;
const copyAndSend = () => {
clipboard.writeText(body);
void shell.openExternal(
`https://github.com/vercel/hyper/issues/new?body=${encodeURIComponent(
'<!-- We have written the needed data into your clipboard because it was too large to send. ' +
'Please paste. -->\n'
)}`
);
};
if (!focusedWindow) {
copyAndSend();
} else if (issueURL.length > 6144) {
void dialog
.showMessageBox(focusedWindow, {
message:
'There is too much data to send to GitHub directly. The data will be copied to the clipboard, ' +
'please paste it into the GitHub issue page that will open.',
type: 'warning',
buttons: ['OK', 'Cancel']
})
.then((result) => {
if (result.response === 0) {
copyAndSend();
}
});
} else {
void shell.openExternal(issueURL);
}
}
}
];
if (process.platform !== 'darwin') {
submenu.push(
{type: 'separator'},
{
label: 'About Hyper',
click() {
showAbout();
}
}
);
}
return {
role: 'help',
submenu
};
};
export default helpMenu;

View file

@ -1,104 +0,0 @@
import type {BrowserWindow, MenuItemConstructorOptions} from 'electron';
const shellMenu = (
commandKeys: Record<string, string>,
execCommand: (command: string, focusedWindow?: BrowserWindow) => void,
profiles: string[]
): MenuItemConstructorOptions => {
const isMac = process.platform === 'darwin';
return {
label: isMac ? 'Shell' : 'File',
submenu: [
{
label: 'New Tab',
accelerator: commandKeys['tab:new'],
click(item, focusedWindow) {
execCommand('tab:new', focusedWindow);
}
},
{
label: 'New Window',
accelerator: commandKeys['window:new'],
click(item, focusedWindow) {
execCommand('window:new', focusedWindow);
}
},
{
type: 'separator'
},
{
label: 'Split Down',
accelerator: commandKeys['pane:splitDown'],
click(item, focusedWindow) {
execCommand('pane:splitDown', focusedWindow);
}
},
{
label: 'Split Right',
accelerator: commandKeys['pane:splitRight'],
click(item, focusedWindow) {
execCommand('pane:splitRight', focusedWindow);
}
},
{
type: 'separator'
},
...profiles.map(
(profile): MenuItemConstructorOptions => ({
label: profile,
submenu: [
{
label: 'New Tab',
accelerator: commandKeys[`tab:new:${profile}`],
click(item, focusedWindow) {
execCommand(`tab:new:${profile}`, focusedWindow);
}
},
{
label: 'New Window',
accelerator: commandKeys[`window:new:${profile}`],
click(item, focusedWindow) {
execCommand(`window:new:${profile}`, focusedWindow);
}
},
{
type: 'separator'
},
{
label: 'Split Down',
accelerator: commandKeys[`pane:splitDown:${profile}`],
click(item, focusedWindow) {
execCommand(`pane:splitDown:${profile}`, focusedWindow);
}
},
{
label: 'Split Right',
accelerator: commandKeys[`pane:splitRight:${profile}`],
click(item, focusedWindow) {
execCommand(`pane:splitRight:${profile}`, focusedWindow);
}
}
]
})
),
{
type: 'separator'
},
{
label: 'Close',
accelerator: commandKeys['pane:close'],
click(item, focusedWindow) {
execCommand('pane:close', focusedWindow);
}
},
{
label: isMac ? 'Close Window' : 'Quit',
role: 'close',
accelerator: commandKeys['window:close']
}
]
};
};
export default shellMenu;

View file

@ -1,49 +0,0 @@
import type {BrowserWindow, MenuItemConstructorOptions} from 'electron';
const toolsMenu = (
commands: Record<string, string>,
execCommand: (command: string, focusedWindow?: BrowserWindow) => void
): MenuItemConstructorOptions => {
return {
label: 'Tools',
submenu: [
{
label: 'Update plugins',
accelerator: commands['plugins:update'],
click() {
execCommand('plugins:update');
}
},
{
label: 'Install Hyper CLI command in PATH',
click() {
execCommand('cli:install');
}
},
{
type: 'separator'
},
...(process.platform === 'win32'
? <MenuItemConstructorOptions[]>[
{
label: 'Add Hyper to system context menu',
click() {
execCommand('systemContextMenu:add');
}
},
{
label: 'Remove Hyper from system context menu',
click() {
execCommand('systemContextMenu:remove');
}
},
{
type: 'separator'
}
]
: [])
]
};
};
export default toolsMenu;

View file

@ -1,59 +0,0 @@
import type {BrowserWindow, MenuItemConstructorOptions} from 'electron';
const viewMenu = (
commandKeys: Record<string, string>,
execCommand: (command: string, focusedWindow?: BrowserWindow) => void
): MenuItemConstructorOptions => {
return {
label: 'View',
submenu: [
{
label: 'Reload',
accelerator: commandKeys['window:reload'],
click(item, focusedWindow) {
execCommand('window:reload', focusedWindow);
}
},
{
label: 'Full Reload',
accelerator: commandKeys['window:reloadFull'],
click(item, focusedWindow) {
execCommand('window:reloadFull', focusedWindow);
}
},
{
label: 'Developer Tools',
accelerator: commandKeys['window:devtools'],
click: (item, focusedWindow) => {
execCommand('window:devtools', focusedWindow);
}
},
{
type: 'separator'
},
{
label: 'Reset Zoom Level',
accelerator: commandKeys['zoom:reset'],
click(item, focusedWindow) {
execCommand('zoom:reset', focusedWindow);
}
},
{
label: 'Zoom In',
accelerator: commandKeys['zoom:in'],
click(item, focusedWindow) {
execCommand('zoom:in', focusedWindow);
}
},
{
label: 'Zoom Out',
accelerator: commandKeys['zoom:out'],
click(item, focusedWindow) {
execCommand('zoom:out', focusedWindow);
}
}
]
};
};
export default viewMenu;

View file

@ -1,98 +0,0 @@
import type {BrowserWindow, MenuItemConstructorOptions} from 'electron';
const windowMenu = (
commandKeys: Record<string, string>,
execCommand: (command: string, focusedWindow?: BrowserWindow) => void
): MenuItemConstructorOptions => {
// Generating tab:jump array
const tabJump: MenuItemConstructorOptions[] = [];
for (let i = 1; i <= 9; i++) {
// 9 is a special number because it means 'last'
const label = i === 9 ? 'Last' : `${i}`;
tabJump.push({
label,
accelerator: commandKeys[`tab:jump:${label.toLowerCase()}`]
});
}
return {
role: 'window',
submenu: [
{
role: 'minimize',
accelerator: commandKeys['window:minimize']
},
{
type: 'separator'
},
{
// It's the same thing as clicking the green traffc-light on macOS
role: 'zoom',
accelerator: commandKeys['window:zoom']
},
{
label: 'Select Tab',
submenu: [
{
label: 'Previous',
accelerator: commandKeys['tab:prev'],
click: (item, focusedWindow) => {
execCommand('tab:prev', focusedWindow);
}
},
{
label: 'Next',
accelerator: commandKeys['tab:next'],
click: (item, focusedWindow) => {
execCommand('tab:next', focusedWindow);
}
},
{
type: 'separator'
},
...tabJump
]
},
{
type: 'separator'
},
{
label: 'Select Pane',
submenu: [
{
label: 'Previous',
accelerator: commandKeys['pane:prev'],
click: (item, focusedWindow) => {
execCommand('pane:prev', focusedWindow);
}
},
{
label: 'Next',
accelerator: commandKeys['pane:next'],
click: (item, focusedWindow) => {
execCommand('pane:next', focusedWindow);
}
}
]
},
{
type: 'separator'
},
{
role: 'front'
},
{
label: 'Toggle Always on Top',
click: (item, focusedWindow) => {
execCommand('window:toggleKeepOnTop', focusedWindow);
}
},
{
role: 'togglefullscreen',
accelerator: commandKeys['window:toggleFullScreen']
}
]
};
};
export default windowMenu;

View file

@ -1,40 +0,0 @@
import type {BrowserWindow} from 'electron';
import fetch from 'electron-fetch';
import ms from 'ms';
import {version} from './package.json';
const NEWS_URL = 'https://hyper-news.now.sh';
export default function fetchNotifications(win: BrowserWindow) {
const {rpc} = win;
const retry = (err?: Error) => {
setTimeout(() => fetchNotifications(win), ms('30m'));
if (err) {
console.error('Notification messages fetch error', err.stack);
}
};
console.log('Checking for notification messages');
fetch(NEWS_URL, {
headers: {
'X-Hyper-Version': version,
'X-Hyper-Platform': process.platform
}
})
.then((res) => res.json())
.then((data) => {
const message: {text: string; url: string; dismissable: boolean} | '' = data.message || '';
if (typeof message !== 'object' && message !== '') {
throw new Error('Bad response');
}
if (message === '') {
console.log('No matching notification messages');
} else {
rpc.emit('add notification', message);
}
retry();
})
.catch(retry);
}

View file

@ -1,21 +0,0 @@
import {app, Notification} from 'electron';
import {icon} from './config/paths';
export default function notify(title: string, body = '', details: {error?: any} = {}) {
console.log(`[Notification] ${title}: ${body}`);
if (details.error) {
console.error(details.error);
}
if (app.isReady()) {
_createNotification(title, body);
} else {
app.on('ready', () => {
_createNotification(title, body);
});
}
}
const _createNotification = (title: string, body: string) => {
new Notification({title, body, ...(process.platform === 'linux' && {icon})}).show();
};

View file

@ -1,47 +1,78 @@
{
"name": "hyper",
"productName": "Hyper",
"description": "A terminal built on web technologies",
"version": "4.0.0-canary.5",
"name": "hyperterm-web",
"version": "0.0.1",
"description": "Web app that runs in the renderer process",
"license": "MIT",
"author": {
"name": "ZEIT, Inc.",
"email": "team@zeit.co"
},
"repository": "zeit/hyper",
"scripts": {
"postinstall": "npx patch-package"
},
"repository": "zeit/hyperterm",
"dependencies": {
"@babel/parser": "7.24.4",
"@electron/remote": "2.1.2",
"ast-types": "^0.16.1",
"async-retry": "1.3.3",
"chokidar": "^3.6.0",
"color": "4.2.3",
"default-shell": "1.0.1",
"electron-devtools-installer": "3.2.0",
"electron-fetch": "1.9.1",
"electron-is-dev": "2.0.0",
"electron-store": "8.2.0",
"fs-extra": "11.2.0",
"git-describe": "4.1.1",
"lodash": "4.17.21",
"ms": "2.1.3",
"native-process-working-directory": "^1.0.2",
"node-pty": "1.0.0",
"os-locale": "5.0.0",
"parse-url": "8.1.0",
"queue": "6.0.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"recast": "0.23.6",
"semver": "7.6.0",
"shell-env": "3.0.1",
"sudo-prompt": "^9.2.1",
"uuid": "9.0.1"
"aphrodite": "0.4.1",
"hterm-umdjs": "1.1.2",
"json-loader": "0.5.4",
"mousetrap": "1.6.0",
"ms": "0.7.1",
"object-values": "1.0.0",
"react": "15.2.1",
"react-addons-pure-render-mixin": "15.2.1",
"react-deep-force-update": "2.0.1",
"react-dom": "15.2.1",
"react-redux": "4.4.5",
"redux": "3.5.2",
"redux-thunk": "2.1.0",
"reselect": "2.5.3",
"seamless-immutable": "6.1.1"
},
"optionalDependencies": {
"native-reg": "1.1.1"
"devDependencies": {
"babel-cli": "6.10.1",
"babel-core": "6.10.4",
"babel-eslint": "6.1.1",
"babel-loader": "6.2.4",
"babel-preset-es2015-native-modules": "6.6.0",
"babel-preset-react": "6.11.1",
"eslint": "3.0.1",
"eslint-config-standard": "5.3.1",
"eslint-plugin-promise": "1.3.2",
"eslint-plugin-react": "5.2.2",
"eslint-plugin-standard": "1.3.2",
"webpack": "2.1.0-beta.15"
},
"eslintConfig": {
"extends": "standard",
"plugins": [
"react"
],
"rules": {
"yoda": 0,
"semi": [
2,
"always"
],
"no-unused-vars": 2,
"no-extra-semi": 2,
"semi-spacing": [
2,
{
"before": false,
"after": true
}
],
"react/jsx-uses-react": 1,
"react/jsx-uses-vars": 1
},
"parserOptions": {
"ecmaFeatures": {
"jsx": true
}
}
},
"babel": {
"presets": [
"es2015-native-modules",
"react"
]
},
"scripts": {
"dev": "webpack --watch",
"lint": "eslint *.js",
"build": "NODE_ENV=production webpack"
}
}

View file

@ -1,15 +0,0 @@
diff --git a/node_modules/node-pty/src/win/conpty.cc b/node_modules/node-pty/src/win/conpty.cc
index 47af75c..884d542 100644
--- a/node_modules/node-pty/src/win/conpty.cc
+++ b/node_modules/node-pty/src/win/conpty.cc
@@ -472,10 +472,6 @@ static NAN_METHOD(PtyKill) {
}
}
- DisconnectNamedPipe(handle->hIn);
- DisconnectNamedPipe(handle->hOut);
- CloseHandle(handle->hIn);
- CloseHandle(handle->hOut);
CloseHandle(handle->hShell);
}

View file

@ -1,480 +0,0 @@
/* eslint-disable eslint-comments/disable-enable-pair */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-call */
import {exec, execFile} from 'child_process';
import {writeFileSync} from 'fs';
import {resolve, basename} from 'path';
import {promisify} from 'util';
import {app, dialog, ipcMain as _ipcMain} from 'electron';
import type {BrowserWindow, App, MenuItemConstructorOptions} from 'electron';
import React from 'react';
import Config from 'electron-store';
import ms from 'ms';
import ReactDom from 'react-dom';
import type {IpcMainWithCommands} from '../typings/common';
import type {configOptions} from '../typings/config';
import * as config from './config';
import {plugs} from './config/paths';
import notify from './notify';
import {availableExtensions} from './plugins/extensions';
import {install} from './plugins/install';
import mapKeys from './utils/map-keys';
// local storage
const cache = new Config();
const path = plugs.base;
const localPath = plugs.local;
patchModuleLoad();
// caches
let plugins = config.getPlugins();
let paths = getPaths();
let id = getId(plugins);
let modules = requirePlugins();
function getId(plugins_: any) {
return JSON.stringify(plugins_);
}
const watchers: Function[] = [];
// we listen on configuration updates to trigger
// plugin installation
config.subscribe(() => {
const plugins_ = config.getPlugins();
if (plugins !== plugins_) {
const id_ = getId(plugins_);
if (id !== id_) {
id = id_;
plugins = plugins_;
updatePlugins();
}
}
});
// patching Module._load
// so plugins can `require` them without needing their own version
// https://github.com/vercel/hyper/issues/619
function patchModuleLoad() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Module = require('module');
const originalLoad = Module._load;
Module._load = function _load(modulePath: string) {
// PLEASE NOTE: Code changes here, also need to be changed in
// lib/utils/plugins.js
switch (modulePath) {
case 'react':
// DEPRECATED
return React;
case 'react-dom':
// DEPRECATED
return ReactDom;
case 'hyper/component':
// DEPRECATED
return React.PureComponent;
// These return Object, since they work differently on the backend, than on the frontend.
// Still needs to be here, to prevent errors, while loading plugins.
case 'hyper/Notification':
case 'hyper/notify':
case 'hyper/decorate':
return Object;
default:
// eslint-disable-next-line prefer-rest-params
return originalLoad.apply(this, arguments);
}
};
}
function checkDeprecatedExtendKeymaps() {
modules.forEach((plugin) => {
if (plugin.extendKeymaps) {
notify('Plugin warning!', `"${plugin._name}" use deprecated "extendKeymaps" handler`);
return;
}
});
}
let updating = false;
function updatePlugins({force = false} = {}) {
if (updating) {
return notify('Plugin update in progress');
}
updating = true;
syncPackageJSON();
const id_ = id;
install((err) => {
updating = false;
if (err) {
notify('Error updating plugins.', err, {error: err});
} else {
// flag successful plugin update
cache.set('hyper.plugins', id_);
// cache paths
paths = getPaths();
// clear require cache
clearCache();
// cache modules
modules = requirePlugins();
const loaded = modules.length;
const total = paths.plugins.length + paths.localPlugins.length;
const pluginVersions = JSON.stringify(getPluginVersions());
const changed = cache.get('hyper.plugin-versions') !== pluginVersions && loaded === total;
cache.set('hyper.plugin-versions', pluginVersions);
// notify watchers
watchers.forEach((fn) => {
fn(err, {force});
});
if (force || changed) {
if (changed) {
notify('Plugins Updated', 'Restart the app or hot-reload with "View" > "Reload" to enjoy the updates!');
} else {
notify('Plugins Updated', 'No changes!');
}
checkDeprecatedExtendKeymaps();
}
}
});
}
function getPluginVersions() {
const paths_ = paths.plugins.concat(paths.localPlugins);
return paths_.map((path_) => {
let version: string | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
version = require(resolve(path_, 'package.json')).version;
//eslint-disable-next-line no-empty
} catch (err) {}
return [basename(path_), version];
});
}
function clearCache() {
// trigger unload hooks
modules.forEach((mod) => {
if (mod.onUnload) {
mod.onUnload(app);
}
});
// clear require cache
for (const entry in require.cache) {
if (entry.indexOf(path) === 0 || entry.indexOf(localPath) === 0) {
delete require.cache[entry];
}
}
}
export {updatePlugins};
export const getLoadedPluginVersions = () => {
return modules.map((mod) => ({name: mod._name, version: mod._version}));
};
// we schedule the initial plugins update
// a bit after the user launches the terminal
// to prevent slowness
if (cache.get('hyper.plugins') !== id || process.env.HYPER_FORCE_UPDATE) {
// install immediately if the user changed plugins
console.log('plugins have changed / not init, scheduling plugins installation');
setTimeout(() => {
updatePlugins();
}, 1000);
}
(() => {
const baseConfig = config.getConfig();
if (baseConfig['autoUpdatePlugins']) {
// otherwise update plugins every 5 hours
setInterval(updatePlugins, ms(baseConfig['autoUpdatePlugins'] === true ? '5h' : baseConfig['autoUpdatePlugins']));
}
})();
function syncPackageJSON() {
const dependencies = toDependencies(plugins);
const pkg = {
name: 'hyper-plugins',
description: 'Auto-generated from `hyper.json`!',
private: true,
version: '0.0.1',
repository: 'vercel/hyper',
license: 'MIT',
homepage: 'https://hyper.is',
dependencies
};
const file = resolve(path, 'package.json');
try {
writeFileSync(file, JSON.stringify(pkg, null, 2));
} catch (err) {
alert(`An error occurred writing to ${file}`);
}
}
function alert(message: string) {
void dialog.showMessageBox({
message,
buttons: ['Ok']
});
}
function toDependencies(plugins_: {plugins: string[]}) {
const obj: Record<string, string> = {};
plugins_.plugins.forEach((plugin) => {
const regex = /.(@|#)/;
const match = regex.exec(plugin);
if (match) {
const index = match.index + 1;
const pieces: string[] = [];
pieces[0] = plugin.substring(0, index);
pieces[1] = plugin.substring(index + 1, plugin.length);
obj[pieces[0]] = pieces[1];
} else {
obj[plugin] = 'latest';
}
});
return obj;
}
export const subscribe = (fn: Function) => {
watchers.push(fn);
return () => {
watchers.splice(watchers.indexOf(fn), 1);
};
};
function getPaths() {
return {
plugins: plugins.plugins.map((name) => {
return resolve(path, 'node_modules', name.split('#')[0]);
}),
localPlugins: plugins.localPlugins.map((name) => {
return resolve(localPath, name);
})
};
}
// expose to renderer
export {getPaths};
// get paths from renderer
export const getBasePaths = () => {
return {path, localPath};
};
function requirePlugins(): any[] {
const {plugins: plugins_, localPlugins} = paths;
const load = (path_: string) => {
let mod: Record<string, any>;
try {
mod = require(path_);
const exposed = mod && Object.keys(mod).some((key) => availableExtensions.has(key));
if (!exposed) {
notify('Plugin error!', `${`Plugin "${basename(path_)}" does not expose any `}Hyper extension API methods`);
return;
}
// populate the name for internal errors here
mod._name = basename(path_);
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
mod._version = require(resolve(path_, 'package.json')).version;
} catch (err) {
console.warn(`No package.json found in ${path_}`);
}
console.log(`Plugin ${mod._name} (${mod._version}) loaded.`);
return mod;
} catch (_err) {
const err = _err as {code: string; message: string};
if (err.code === 'MODULE_NOT_FOUND') {
console.warn(`Plugin error while loading "${basename(path_)}" (${path_}): ${err.message}`);
} else {
notify('Plugin error!', `Plugin "${basename(path_)}" failed to load (${err.message})`, {error: err});
}
}
};
return [
...localPlugins.filter((p) => basename(p) === 'migrated-hyper3-config'),
...plugins_,
...localPlugins.filter((p) => basename(p) !== 'migrated-hyper3-config')
]
.map(load)
.filter((v): v is Record<string, any> => Boolean(v));
}
export const onApp = (app_: App) => {
modules.forEach((plugin) => {
if (plugin.onApp) {
try {
plugin.onApp(app_);
} catch (e) {
notify('Plugin error!', `"${plugin._name}" has encountered an error. Check Developer Tools for details.`, {
error: e
});
}
}
});
};
export const onWindowClass = (win: BrowserWindow) => {
modules.forEach((plugin) => {
if (plugin.onWindowClass) {
try {
plugin.onWindowClass(win);
} catch (e) {
notify('Plugin error!', `"${plugin._name}" has encountered an error. Check Developer Tools for details.`, {
error: e
});
}
}
});
};
export const onWindow = (win: BrowserWindow) => {
modules.forEach((plugin) => {
if (plugin.onWindow) {
try {
plugin.onWindow(win);
} catch (e) {
notify('Plugin error!', `"${plugin._name}" has encountered an error. Check Developer Tools for details.`, {
error: e
});
}
}
});
};
// decorates the base entity by calling plugin[key]
// for all the available plugins
function decorateEntity(base: any, key: string, type: 'object' | 'function') {
let decorated = base;
modules.forEach((plugin) => {
if (plugin[key]) {
let res;
try {
res = plugin[key](decorated);
} catch (e) {
notify('Plugin error!', `"${plugin._name}" when decorating ${key}`, {error: e});
return;
}
if (res && (!type || typeof res === type)) {
decorated = res;
} else {
notify('Plugin error!', `"${plugin._name}": invalid return type for \`${key}\``);
}
}
});
return decorated;
}
function decorateObject<T>(base: T, key: string): T {
return decorateEntity(base, key, 'object');
}
function decorateClass(base: any, key: string) {
return decorateEntity(base, key, 'function');
}
export const getDeprecatedConfig = () => {
const deprecated: Record<string, {css: string[]}> = {};
const baseConfig = config.getConfig();
modules.forEach((plugin) => {
if (!plugin.decorateConfig) {
return;
}
// We need to clone config in case of plugin modifies config directly.
let configTmp: configOptions;
try {
configTmp = plugin.decorateConfig(JSON.parse(JSON.stringify(baseConfig)));
} catch (e) {
notify('Plugin error!', `"${plugin._name}" has encountered an error. Check Developer Tools for details.`, {
error: e
});
return;
}
const pluginCSSDeprecated = config.getDeprecatedCSS(configTmp);
if (pluginCSSDeprecated.length === 0) {
return;
}
deprecated[plugin._name] = {css: pluginCSSDeprecated};
});
return deprecated;
};
export const decorateMenu = (tpl: MenuItemConstructorOptions[]) => {
return decorateObject(tpl, 'decorateMenu');
};
export const getDecoratedEnv = (baseEnv: Record<string, string>) => {
return decorateObject(baseEnv, 'decorateEnv');
};
export const getDecoratedConfig = (profile: string) => {
const baseConfig = config.getProfileConfig(profile);
const decoratedConfig = decorateObject(baseConfig, 'decorateConfig');
const fixedConfig = config.fixConfigDefaults(decoratedConfig);
const translatedConfig = config.htermConfigTranslate(fixedConfig);
return translatedConfig;
};
export const getDecoratedKeymaps = () => {
const baseKeymaps = config.getKeymaps();
// Ensure that all keys are in an array and don't use deprecated key combination`
const decoratedKeymaps = mapKeys(decorateObject(baseKeymaps, 'decorateKeymaps'));
return decoratedKeymaps;
};
export const getDecoratedBrowserOptions = <T>(defaults: T): T => {
return decorateObject(defaults, 'decorateBrowserOptions');
};
export const decorateWindowClass = <T>(defaults: T): T => {
return decorateObject(defaults, 'decorateWindowClass');
};
export const decorateSessionOptions = <T>(defaults: T): T => {
return decorateObject(defaults, 'decorateSessionOptions');
};
export const decorateSessionClass = <T>(Session: T): T => {
return decorateClass(Session, 'decorateSessionClass');
};
export {toDependencies as _toDependencies};
const ipcMain = _ipcMain as IpcMainWithCommands;
ipcMain.handle('child_process.exec', (event, command, options) => {
return promisify(exec)(command, options);
});
ipcMain.handle('child_process.execFile', (event, file, args, options) => {
return promisify(execFile)(file, args, options);
});
ipcMain.handle('getLoadedPluginVersions', () => getLoadedPluginVersions());
ipcMain.handle('getPaths', () => getPaths());
ipcMain.handle('getBasePaths', () => getBasePaths());
ipcMain.handle('getDeprecatedConfig', () => getDeprecatedConfig());
ipcMain.handle('getDecoratedConfig', (e, profile) => getDecoratedConfig(profile));
ipcMain.handle('getDecoratedKeymaps', () => getDecoratedKeymaps());

View file

@ -1,42 +0,0 @@
export const availableExtensions = new Set([
'onApp',
'onWindowClass',
'decorateWindowClass',
'onWindow',
'onRendererWindow',
'onUnload',
'decorateSessionClass',
'decorateSessionOptions',
'middleware',
'reduceUI',
'reduceSessions',
'reduceTermGroups',
'decorateBrowserOptions',
'decorateMenu',
'decorateTerm',
'decorateHyper',
'decorateHyperTerm', // for backwards compatibility with hyperterm
'decorateHeader',
'decorateTerms',
'decorateTab',
'decorateNotification',
'decorateNotifications',
'decorateTabs',
'decorateConfig',
'decorateKeymaps',
'decorateEnv',
'decorateTermGroup',
'decorateSplitPane',
'getTermProps',
'getTabProps',
'getTabsProps',
'getTermGroupProps',
'mapHyperTermState',
'mapTermsState',
'mapHeaderState',
'mapNotificationsState',
'mapHyperTermDispatch',
'mapTermsDispatch',
'mapHeaderDispatch',
'mapNotificationsDispatch'
]);

View file

@ -1,49 +0,0 @@
import cp from 'child_process';
import ms from 'ms';
import queue from 'queue';
import {yarn, plugs} from '../config/paths';
export const install = (fn: (err: string | null) => void) => {
const spawnQueue = queue({concurrency: 1});
function yarnFn(args: string[], cb: (err: string | null) => void) {
const env = {
NODE_ENV: 'production',
ELECTRON_RUN_AS_NODE: 'true'
};
spawnQueue.push((end) => {
const cmd = [process.execPath, yarn].concat(args).join(' ');
console.log('Launching yarn:', cmd);
cp.execFile(
process.execPath,
[yarn].concat(args),
{
cwd: plugs.base,
env,
timeout: ms('5m'),
maxBuffer: 1024 * 1024
},
(err, stdout, stderr) => {
if (err) {
cb(stderr);
} else {
cb(null);
}
end?.();
spawnQueue.start();
}
);
});
spawnQueue.start();
}
yarnFn(['install', '--no-emoji', '--no-lockfile', '--cache-folder', plugs.cache], (err) => {
if (err) {
return fn(err);
}
fn(null);
});
};

View file

@ -1,83 +0,0 @@
import {EventEmitter} from 'events';
import {ipcMain} from 'electron';
import type {BrowserWindow, IpcMainEvent} from 'electron';
import {v4 as uuidv4} from 'uuid';
import type {TypedEmitter, MainEvents, RendererEvents, FilterNever} from '../typings/common';
export class Server {
emitter: TypedEmitter<MainEvents>;
destroyed = false;
win: BrowserWindow;
id!: string;
constructor(win: BrowserWindow) {
this.emitter = new EventEmitter();
this.win = win;
this.emit = this.emit.bind(this);
if (this.destroyed) {
return;
}
const uid = uuidv4();
this.id = uid;
ipcMain.on(uid, this.ipcListener);
// we intentionally subscribe to `on` instead of `once`
// to support reloading the window and re-initializing
// the channel
this.wc.on('did-finish-load', () => {
this.wc.send('init', uid, win.profileName);
});
}
get wc() {
return this.win.webContents;
}
ipcListener = <U extends keyof MainEvents>(event: IpcMainEvent, {ev, data}: {ev: U; data: MainEvents[U]}) =>
this.emitter.emit(ev, data);
on = <U extends keyof MainEvents>(ev: U, fn: (arg0: MainEvents[U]) => void) => {
this.emitter.on(ev, fn);
return this;
};
once = <U extends keyof MainEvents>(ev: U, fn: (arg0: MainEvents[U]) => void) => {
this.emitter.once(ev, fn);
return this;
};
emit<U extends Exclude<keyof RendererEvents, FilterNever<RendererEvents>>>(ch: U): boolean;
emit<U extends FilterNever<RendererEvents>>(ch: U, data: RendererEvents[U]): boolean;
emit<U extends keyof RendererEvents>(ch: U, data?: RendererEvents[U]) {
// This check is needed because data-batching can cause extra data to be
// emitted after the window has already closed
if (!this.win.isDestroyed()) {
this.wc.send(this.id, {ch, data});
return true;
}
return false;
}
destroy() {
this.emitter.removeAllListeners();
this.wc.removeAllListeners();
if (this.id) {
ipcMain.removeListener(this.id, this.ipcListener);
} else {
// mark for `genUid` in constructor
this.destroyed = true;
}
}
}
const createRPC = (win: BrowserWindow) => {
return new Server(win);
};
export default createRPC;

View file

@ -1,263 +0,0 @@
import {EventEmitter} from 'events';
import {dirname} from 'path';
import {StringDecoder} from 'string_decoder';
import defaultShell from 'default-shell';
import type {IPty, IWindowsPtyForkOptions, spawn as npSpawn} from 'node-pty';
import osLocale from 'os-locale';
import shellEnv from 'shell-env';
import * as config from './config';
import {cliScriptPath} from './config/paths';
import {productName, version} from './package.json';
import {getDecoratedEnv} from './plugins';
import {getFallBackShellConfig} from './utils/shell-fallback';
const createNodePtyError = () =>
new Error(
'`node-pty` failed to load. Typically this means that it was built incorrectly. Please check the `readme.md` to more info.'
);
let spawn: typeof npSpawn;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
spawn = require('node-pty').spawn;
} catch (err) {
throw createNodePtyError();
}
const useConpty = config.getConfig().useConpty;
// Max duration to batch session data before sending it to the renderer process.
const BATCH_DURATION_MS = 16;
// Max size of a session data batch. Note that this value can be exceeded by ~4k
// (chunk sizes seem to be 4k at the most)
const BATCH_MAX_SIZE = 200 * 1024;
// Data coming from the pty is sent to the renderer process for further
// vt parsing and rendering. This class batches data to minimize the number of
// IPC calls. It also reduces GC pressure and CPU cost: each chunk is prefixed
// with the window ID which is then stripped on the renderer process and this
// overhead is reduced with batching.
class DataBatcher extends EventEmitter {
uid: string;
decoder: StringDecoder;
data!: string;
timeout!: NodeJS.Timeout | null;
constructor(uid: string) {
super();
this.uid = uid;
this.decoder = new StringDecoder('utf8');
this.reset();
}
reset() {
this.data = this.uid;
this.timeout = null;
}
write(chunk: Buffer | string) {
if (this.data.length + chunk.length >= BATCH_MAX_SIZE) {
// We've reached the max batch size. Flush it and start another one
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
this.flush();
}
this.data += typeof chunk === 'string' ? chunk : this.decoder.write(chunk);
if (!this.timeout) {
this.timeout = setTimeout(() => this.flush(), BATCH_DURATION_MS);
}
}
flush() {
// Reset before emitting to allow for potential reentrancy
const data = this.data;
this.reset();
this.emit('flush', data);
}
}
interface SessionOptions {
uid: string;
rows?: number;
cols?: number;
cwd?: string;
shell?: string;
shellArgs?: string[];
profile: string;
}
export default class Session extends EventEmitter {
pty: IPty | null;
batcher: DataBatcher | null;
shell: string | null;
ended: boolean;
initTimestamp: number;
profile!: string;
constructor(options: SessionOptions) {
super();
this.pty = null;
this.batcher = null;
this.shell = null;
this.ended = false;
this.initTimestamp = new Date().getTime();
this.init(options);
}
init({uid, rows, cols, cwd, shell: _shell, shellArgs: _shellArgs, profile}: SessionOptions) {
this.profile = profile;
const envFromConfig = config.getProfileConfig(profile).env || {};
const defaultShellArgs = ['--login'];
const shell = _shell || defaultShell;
const shellArgs = _shellArgs || defaultShellArgs;
const cleanEnv =
process.env['APPIMAGE'] && process.env['APPDIR'] ? shellEnv.sync(_shell || defaultShell) : process.env;
const baseEnv: Record<string, string> = {
...cleanEnv,
LANG: `${osLocale.sync().replace(/-/, '_')}.UTF-8`,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
TERM_PROGRAM: productName,
TERM_PROGRAM_VERSION: version,
...envFromConfig
};
// path to AppImage mount point is added to PATH environment variable automatically
// which conflicts with the cli
if (baseEnv['APPIMAGE'] && baseEnv['APPDIR']) {
baseEnv['PATH'] = [dirname(cliScriptPath)]
.concat((baseEnv['PATH'] || '').split(':').filter((val) => !val.startsWith(baseEnv['APPDIR'])))
.join(':');
}
// Electron has a default value for process.env.GOOGLE_API_KEY
// We don't want to leak this to the shell
// See https://github.com/vercel/hyper/issues/696
if (baseEnv.GOOGLE_API_KEY && process.env.GOOGLE_API_KEY === baseEnv.GOOGLE_API_KEY) {
delete baseEnv.GOOGLE_API_KEY;
}
const options: IWindowsPtyForkOptions = {
cols,
rows,
cwd,
env: getDecoratedEnv(baseEnv)
};
// if config do not set the useConpty, it will be judged by the node-pty
if (typeof useConpty === 'boolean') {
options.useConpty = useConpty;
}
try {
this.pty = spawn(shell, shellArgs, options);
} catch (_err) {
const err = _err as {message: string};
if (/is not a function/.test(err.message)) {
throw createNodePtyError();
} else {
throw err;
}
}
this.batcher = new DataBatcher(uid);
this.pty.onData((chunk) => {
if (this.ended) {
return;
}
this.batcher?.write(chunk);
});
this.batcher.on('flush', (data: string) => {
this.emit('data', data);
});
this.pty.onExit((e) => {
if (!this.ended) {
// fall back to default shell config if the shell exits within 1 sec with non zero exit code
// this will inform users in case there are errors in the config instead of instant exit
const runDuration = new Date().getTime() - this.initTimestamp;
if (e.exitCode > 0 && runDuration < 1000) {
const fallBackShellConfig = getFallBackShellConfig(shell, shellArgs, defaultShell, defaultShellArgs);
if (fallBackShellConfig) {
const msg = `
shell exited in ${runDuration} ms with exit code ${e.exitCode}
please check the shell config: ${JSON.stringify({shell, shellArgs}, undefined, 2)}
using fallback shell config: ${JSON.stringify(fallBackShellConfig, undefined, 2)}
`;
console.warn(msg);
this.batcher?.write(msg.replace(/\n/g, '\r\n'));
this.init({
uid,
rows,
cols,
cwd,
shell: fallBackShellConfig.shell,
shellArgs: fallBackShellConfig.shellArgs,
profile
});
} else {
const msg = `
shell exited in ${runDuration} ms with exit code ${e.exitCode}
No fallback available, please check the shell config.
`;
console.warn(msg);
this.batcher?.write(msg.replace(/\n/g, '\r\n'));
}
} else {
this.ended = true;
this.emit('exit');
}
}
});
this.shell = shell;
}
exit() {
this.destroy();
}
write(data: string) {
if (this.pty) {
this.pty.write(data);
} else {
console.warn('Warning: Attempted to write to a session with no pty');
}
}
resize({cols, rows}: {cols: number; rows: number}) {
if (this.pty) {
try {
this.pty.resize(cols, rows);
} catch (_err) {
const err = _err as {stack: any};
console.error(err.stack);
}
} else {
console.warn('Warning: Attempted to resize a session with no pty');
}
}
destroy() {
if (this.pty) {
try {
this.pty.kill();
} catch (_err) {
const err = _err as {stack: any};
console.error('exit error', err.stack);
}
} else {
console.warn('Warning: Attempted to destroy a session with no pty');
}
this.emit('exit');
this.ended = true;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -1,14 +0,0 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"declarationDir": "../dist/tmp/appdts/",
"outDir": "../target/",
"noImplicitAny": false
},
"include": [
"./**/*",
"./package.json",
"../typings/extend-electron.d.ts",
"../typings/ext-modules.d.ts"
]
}

Some files were not shown because too many files have changed in this diff Show more