diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index bc7e8194..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,97 +0,0 @@ -version: 2 -jobs: - install: - macos: - xcode: "9.2.0" - working_directory: ~/repo - steps: - - checkout - - restore_cache: - key: cache-{{ checksum "yarn.lock" }} - - run: - name: Installing Dependencies - command: yarn --ignore-engines - - save_cache: - key: cache-{{ checksum "yarn.lock" }} - paths: - - node_modules - - run: - name: Getting build icon - command: if [[ $CIRCLE_BRANCH == canary ]]; then cp build/canary.icns build/icon.icns; fi - - persist_to_workspace: - root: . - paths: - - node_modules - - test: - macos: - xcode: "9.2.0" - steps: - - checkout - - attach_workspace: - at: . - - run: - name: Testing - command: yarn test - - build: - macos: - xcode: "9.2.0" - steps: - - checkout - - attach_workspace: - at: . - - run: - name: Building - command: yarn dist --publish 'never' - - store_artifacts: - path: dist - - persist_to_workspace: - root: . - paths: - - dist - - release: - macos: - xcode: "9.2.0" - steps: - - checkout - - attach_workspace: - at: . - - run: - name: Deploying to GitHub - command: yarn dist - - -workflows: - version: 2 - build: - jobs: - - install: - filters: - tags: - only: /.*/ - - test: - requires: - - install - filters: - tags: - only: /.*/ - - build: - requires: - - test - filters: - branches: - only: - - master - - canary - tags: - ignore: /.*/ - - release: - requires: - - test - filters: - tags: - only: /.*/ - branches: - ignore: /.*/ diff --git a/.eslintignore b/.eslintignore index 9344d5af..7c25c8ac 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,7 +4,11 @@ app/static app/bin app/dist app/node_modules +app/typings assets website bin -dist \ No newline at end of file +dist +target +cache +schema.json \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..1fa89a98 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,161 @@ +{ + "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" + } + } + ] +} diff --git a/.gitattributes b/.gitattributes index 391f0a4e..97c115ec 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ * text=auto *.js text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +bin/* linguist-vendored diff --git a/.github/issue_template.md b/.github/ISSUE_TEMPLATE/bug_report.md similarity index 70% rename from .github/issue_template.md rename to .github/ISSUE_TEMPLATE/bug_report.md index b7bef5b2..15db91f4 100644 --- a/.github/issue_template.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,14 +1,23 @@ +--- +name: Bug report +about: Create a report to help Hyper improve +title: '' +labels: '' +assignees: '' + +--- + -- [ ] I am on the [latest](https://github.com/zeit/hyper/releases/latest) Hyper.app version -- [ ] I have searched the [issues](https://github.com/zeit/hyper/issues) of this repo and believe that this is not a duplicate +- [ ] 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 - **Hyper.app version**: -- **Link of a [Gist](https://gist.github.com/) with the contents of your .hyper.js**: +- **Link of a [Gist](https://gist.github.com/) with the contents of your hyper.json**: - **Relevant information from devtools** _(CMD+ALT+I on macOS, CTRL+SHIFT+I elsewhere)_: - **The issue is reproducible in vanilla Hyper.app**: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..e28a3f4c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..20a51554 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +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 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9d8542bd..31a64d36 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -3,6 +3,6 @@ - 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/zeit/hyper-site. +- If your PR changes some API, please make a PR for hyper website too: https://github.com/vercel/hyper-site. Thanks, again! --> diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..569dda76 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,67 @@ +# 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 diff --git a/.github/workflows/e2e_comment.yml b/.github/workflows/e2e_comment.yml new file mode 100644 index 00000000..573a4d59 --- /dev/null +++ b/.github/workflows/e2e_comment.yml @@ -0,0 +1,63 @@ +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 }} diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 00000000..d42e2995 --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -0,0 +1,190 @@ +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 diff --git a/.gitignore b/.gitignore index d92be03d..9c5932fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ # build output dist app/renderer +target bin/cli.* +cache # dependencies node_modules @@ -11,6 +13,11 @@ npm-debug.log yarn-error.log # optional dev config file and plugins directory -.hyper.js -.hyper_plugins +hyper.json +schema.json +plugins +.DS_Store +.vscode/* +!.vscode/launch.json +.idea diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 00000000..31354ec1 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 00000000..f077c917 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9d70f7e5..00000000 --- a/.travis.yml +++ /dev/null @@ -1,41 +0,0 @@ -sudo: required -dist: trusty - -language: node_js - -matrix: - include: - - os: linux - node_js: 8 - env: CC=clang CXX=clang++ npm_config_clang=1 - compiler: clang - -addons: - apt: - packages: - - gcc-multilib - - g++-multilib - - libgnome-keyring-dev - - icnsutils - - graphicsmagick - - xz-utils - - rpm - - bsdtar - - snapd - -before_install: - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then sudo snap install snapcraft --classic; fi - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then export DISPLAY=:99.0; sh -e /etc/init.d/xvfb start; sleep 3; fi - -cache: yarn - -install: - - yarn - -after_success: - - (git branch --contains $TRAVIS_COMMIT | grep canary > /dev/null || [[ "$TRAVIS_BRANCH" == "canary" ]] ) && (cd build; cp canary.icns icon.icns; cp canary.ico icon.ico) - - yarn run dist - -branches: - except: - - "/^v\\d+\\.\\d+\\.\\d+$/" diff --git a/.vscode/launch.json b/.vscode/launch.json index 9f50ef8f..747bb7b0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "request": "launch", "name": "Launch Hyper", "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron", - "program": "${workspaceRoot}/app/index.js", + "program": "${workspaceRoot}/target/index.js", "protocol": "inspector" }, { diff --git a/.yarnrc b/.yarnrc index 3d567722..45291c13 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1 +1 @@ -save-exact true +registry "https://registry.npmjs.org/" diff --git a/LICENSE b/LICENSE index 89491ddb..fe231dc9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ # MIT License -Copyright (c) 2018 ZEIT, Inc. +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 diff --git a/PLUGINS.md b/PLUGINS.md index e5ddd315..afc284a3 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -3,25 +3,21 @@ ## 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/zeit/hyper#contribute). +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/zeit/hyper#contribute). +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.js` 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 `.hyper_plugins` directory in your repository directory. +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 `/.hyper_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. +Go to your recently created `/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: { @@ -34,24 +30,20 @@ module.exports = { ``` ### Running your plugin - -To load, your plugin should expose at least one API method. All possible methods are listed [here](https://github.com/zeit/hyper/blob/canary/app/plugins/extensions.js). +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 [here](https://www.hyper.is). +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 hierachy. +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 { @@ -79,33 +71,27 @@ exports.decorateTerms = (Terms, {React}) => { } } ``` - :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" - }; + '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: `:`. 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', @@ -115,50 +101,43 @@ Hotkeys are composed by [Mousetrap supported keys](https://craig.is/killing/mice '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 => { + '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", () => { +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"; +exports.decorateMenu = (menu) => { + debug('decorateMenu'); + const isMac = process.platform === 'darwin'; // menu label is different on mac - const menuLabel = isMac ? "Shell" : "File"; + const menuLabel = isMac ? 'Shell' : 'File'; return menu.map(menuCategory => { if (menuCategory.label !== menuLabel) { @@ -167,55 +146,63 @@ exports.decorateMenu = menu => { return [ ...menuCategory, { - type: "separator" + type: 'separator' }, { - label: "Clear all panes in all tabs", - accelerator: "ctrl+shift+y", + 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"); + 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", () => { + 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) { + 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 */ } - }; -}; + } +} ``` -## Hyper v2 breaking changes +### Require Electron +Hyper doesn't provide a reference to electron. However plugins can directly require electron. -Hyper v2 uses `xterm.js` instead of `hterm`. It means that PTY ouput renders now in a canvas element, not with a hackable DOM structure. +```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. diff --git a/README.md b/README.md index fe6e7a54..8c11085e 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,39 @@ -![](https://assets.zeit.co/image/upload/v1537650716/repositories/hyper/hyper-repo-banner.png) +![](https://assets.vercel.com/image/upload/v1549723846/repositories/hyper/hyper-3-repo-banner.png) -[![macOS CI Status](https://circleci.com/gh/zeit/hyper.svg?style=shield)](https://circleci.com/gh/zeit/hyper) -[![Windows CI status](https://ci.appveyor.com/api/projects/status/kqvb4oa772an58sc?svg=true)](https://ci.appveyor.com/project/zeit/hyper) -[![Linux CI status](https://travis-ci.org/zeit/hyper.svg?branch=master)](https://travis-ci.org/zeit/hyper) +

+ + + +

+ +[![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) -[![Join the community on Spectrum](https://withspectrum.github.io/badge/badge.svg)](https://spectrum.chat/zeit/hyper) 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 like [aurman](https://github.com/polygamma/aurman) +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 -aurman -S hyper +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 @@ -28,7 +42,7 @@ Use [Homebrew Cask](https://brew.sh) to download the app by running these comman ```bash brew update -brew cask install hyper +brew install --cask hyper ``` ### Windows @@ -39,36 +53,33 @@ Use [chocolatey](https://chocolatey.org/) to install the app by running the foll choco install hyper ``` -**Note:** The version available on [Homebrew Cask](https://brew.sh), [Chocolatey](https://chocolatey.org) 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. +**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. ## 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` 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. +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: @@ -88,6 +99,10 @@ make sure its build process is working correctly by running `yarn run rebuild-no 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 @@ -95,8 +110,7 @@ If you have issues in the `codesign` step when running `yarn run dist` on macOS, ## Related Repositories -* [Art](https://github.com/zeit/art/tree/master/hyper) -* [Website](https://github.com/zeit/hyper-site) -* [Sample Extension](https://github.com/zeit/hyperpower) -* [Sample Theme](https://github.com/zeit/hyperyellow) -* [Awesome Hyper](https://github.com/bnb/awesome-hyper) +- [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) diff --git a/app/.yarnrc b/app/.yarnrc new file mode 100644 index 00000000..45291c13 --- /dev/null +++ b/app/.yarnrc @@ -0,0 +1 @@ +registry "https://registry.npmjs.org/" diff --git a/app/auto-updater-linux.js b/app/auto-updater-linux.ts similarity index 53% rename from app/auto-updater-linux.js rename to app/auto-updater-linux.ts index 31dd23d8..aa95c1d7 100644 --- a/app/auto-updater-linux.js +++ b/app/auto-updater-linux.ts @@ -1,9 +1,9 @@ -'use strict'; +import {EventEmitter} from 'events'; -const fetch = require('electron-fetch'); -const {EventEmitter} = require('events'); +import fetch from 'electron-fetch'; -class AutoUpdater extends EventEmitter { +class AutoUpdater extends EventEmitter implements Electron.AutoUpdater { + updateURL!: string; quitAndInstall() { this.emitError('QuitAndInstall unimplemented'); } @@ -11,8 +11,8 @@ class AutoUpdater extends EventEmitter { return this.updateURL; } - setFeedURL(updateURL) { - this.updateURL = updateURL; + setFeedURL(options: Electron.FeedURLOptions) { + this.updateURL = options.url; } checkForUpdates() { @@ -22,29 +22,31 @@ class AutoUpdater extends EventEmitter { this.emit('checking-for-update'); fetch(this.updateURL) - .then(res => { + .then((res) => { if (res.status === 204) { - return this.emit('update-not-available'); + this.emit('update-not-available'); + return; } - return res.json().then(({name, notes, pub_date}) => { + 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.'); } - // If `null` is passed to Date constructor, current time will be used. This doesn't work with `undefined` - const date = new Date(pub_date || null); + const date = pub_date ? new Date(pub_date) : new Date(); this.emit('update-available', {}, notes, name, date); }); }) .catch(this.emitError.bind(this)); } - emitError(error) { + emitError(error: string | Error) { if (typeof error === 'string') { error = new Error(error); } - this.emit('error', error, error.message); + this.emit('error', error); } } -module.exports = new AutoUpdater(); +const autoUpdaterLinux = new AutoUpdater(); + +export default autoUpdaterLinux; diff --git a/app/commands.js b/app/commands.js deleted file mode 100644 index 498573cb..00000000 --- a/app/commands.js +++ /dev/null @@ -1,122 +0,0 @@ -const {app} = require('electron'); -const {openConfig} = require('./config'); -const {updatePlugins} = require('./plugins'); -const {installCLI} = require('./utils/cli-install'); - -const commands = { - '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:splitVertical': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('split request vertical'); - }, - 'pane:splitHorizontal': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('split request horizontal'); - }, - 'pane:close': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('termgroup close req'); - }, - 'window:preferences': () => { - openConfig(); - }, - 'editor:clearBuffer': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('session clear req'); - }, - 'editor:selectAll': focusedWindow => { - focusedWindow.rpc.emit('term selectAll'); - }, - 'plugins:update': () => { - updatePlugins(); - }, - 'window:reload': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('reload'); - }, - 'window:reloadFull': focusedWindow => { - 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 && focusedWindow.rpc.emit('reset fontSize req'); - }, - 'zoom:in': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('increase fontSize req'); - }, - 'zoom:out': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('decrease fontSize req'); - }, - 'tab:prev': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('move left req'); - }, - 'tab:next': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('move right req'); - }, - 'pane:prev': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('prev pane req'); - }, - 'pane:next': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('next pane req'); - }, - 'editor:movePreviousWord': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('session move word left req'); - }, - 'editor:moveNextWord': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('session move word right req'); - }, - 'editor:moveBeginningLine': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('session move line beginning req'); - }, - 'editor:moveEndLine': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('session move line end req'); - }, - 'editor:deletePreviousWord': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('session del word left req'); - }, - 'editor:deleteNextWord': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('session del word right req'); - }, - 'editor:deleteBeginningLine': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('session del line beginning req'); - }, - 'editor:deleteEndLine': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('session del line end req'); - }, - 'editor:break': focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('session break req'); - }, - 'cli:install': () => { - installCLI(true); - } -}; - -//Special numeric command -[1, 2, 3, 4, 5, 6, 7, 8, 'last'].forEach(cmdIndex => { - const index = cmdIndex === 'last' ? cmdIndex : cmdIndex - 1; - commands[`tab:jump:${cmdIndex}`] = focusedWindow => { - focusedWindow && focusedWindow.rpc.emit('move jump req', index); - }; -}); - -exports.execCommand = (command, focusedWindow) => { - const fn = commands[command]; - if (fn) { - fn(focusedWindow); - } -}; diff --git a/app/commands.ts b/app/commands.ts new file mode 100644 index 00000000..469a5a3d --- /dev/null +++ b/app/commands.ts @@ -0,0 +1,170 @@ +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 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); + } +}; diff --git a/app/config.js b/app/config.js deleted file mode 100644 index 6c2a80a3..00000000 --- a/app/config.js +++ /dev/null @@ -1,154 +0,0 @@ -const fs = require('fs'); -const notify = require('./notify'); -const {_import, getDefaultConfig} = require('./config/import'); -const _openConfig = require('./config/open'); -const win = require('./config/windows'); -const {cfgPath, cfgDir} = require('./config/paths'); -const {getColorMap} = require('./utils/colors'); - -const watchers = []; -let cfg = {}; -let _watcher; - -const _watch = function() { - if (_watcher) { - return _watcher; - } - - 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); - }; - - // Windows - if (process.platform === 'win32') { - // watch for changes on config every 2s on Windows - // https://github.com/zeit/hyper/pull/1772 - _watcher = fs.watchFile(cfgPath, {interval: 2000}, (curr, prev) => { - if (curr.mtime === 0) { - //eslint-disable-next-line no-console - console.error('error watching config'); - } else if (curr.mtime !== prev.mtime) { - onChange(); - } - }); - return; - } - // macOS/Linux - setWatcher(); - function setWatcher() { - try { - _watcher = fs.watch(cfgPath, eventType => { - if (eventType === 'rename') { - _watcher.close(); - // Ensure that new file has been written - setTimeout(() => setWatcher(), 500); - } - }); - } catch (e) { - //eslint-disable-next-line no-console - console.error('Failed to watch config file:', cfgPath, e); - return; - } - _watcher.on('change', onChange); - _watcher.on('error', error => { - //eslint-disable-next-line no-console - console.error('error watching config', error); - }); - } -}; - -exports.subscribe = fn => { - watchers.push(fn); - return () => { - watchers.splice(watchers.indexOf(fn), 1); - }; -}; - -exports.getConfigDir = () => { - // expose config directory to load plugin from the right place - return cfgDir; -}; - -exports.getConfig = () => { - return cfg.config; -}; - -exports.openConfig = () => { - return _openConfig(); -}; - -exports.getPlugins = () => { - return { - plugins: cfg.plugins, - localPlugins: cfg.localPlugins - }; -}; - -exports.getKeymaps = () => { - return cfg.keymaps; -}; - -exports.setup = () => { - cfg = _import(); - _watch(); - checkDeprecatedConfig(); -}; - -exports.getWin = win.get; -exports.winRecord = win.recordState; -exports.windowDefaults = win.defaults; - -const getDeprecatedCSS = function(config) { - const deprecated = []; - const deprecatedCSS = ['x-screen', 'x-row', 'cursor-node', '::selection']; - deprecatedCSS.forEach(css => { - if ((config.css && config.css.indexOf(css) !== -1) || (config.termCSS && config.termCSS.indexOf(css) !== -1)) { - deprecated.push(css); - } - }); - return deprecated; -}; -exports.getDeprecatedCSS = getDeprecatedCSS; - -const checkDeprecatedConfig = function() { - 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})`); -}; - -exports.fixConfigDefaults = decoratedConfig => { - const defaultConfig = getDefaultConfig().config; - decoratedConfig.colors = getColorMap(decoratedConfig.colors) || {}; - // We must have default colors for xterm css. - decoratedConfig.colors = Object.assign({}, defaultConfig.colors, decoratedConfig.colors); - return decoratedConfig; -}; - -exports.htermConfigTranslate = config => { - const cssReplacements = { - '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 && config.css.replace(searchvalue, newvalue); - config.termCSS = config.termCSS && config.termCSS.replace(searchvalue, newvalue); - }); - return config; -}; diff --git a/app/config.ts b/app/config.ts new file mode 100644 index 00000000..b4613d57 --- /dev/null +++ b/app/config.ts @@ -0,0 +1,156 @@ +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 = { + '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; +}; diff --git a/app/config/config-default.js b/app/config/config-default.js deleted file mode 100644 index 7d3ce319..00000000 --- a/app/config/config-default.js +++ /dev/null @@ -1,151 +0,0 @@ -// Future versions of Hyper may add additional config options, -// which will not automatically be merged into this file. -// See https://hyper.is#cfg for all currently supported options. - -module.exports = { - config: { - // choose either `'stable'` for receiving highly polished, - // or `'canary'` for less polished but more frequent updates - updateChannel: 'stable', - - // default font size in pixels for all tabs - fontSize: 12, - - // font family with optional fallbacks - fontFamily: 'Menlo, "DejaVu Sans Mono", Consolas, "Lucida Console", monospace', - - // default font weight: 'normal' or 'bold' - fontWeight: 'normal', - - // font weight for bold characters: 'normal' or 'bold' - fontWeightBold: 'bold', - - // line height as a relative unit - lineHeight: 1, - - // letter spacing as a relative unit - letterSpacing: 0, - - // terminal cursor background color and opacity (hex, rgb, hsl, hsv, hwb or cmyk) - cursorColor: 'rgba(248,28,229,0.8)', - - // terminal text color under BLOCK cursor - cursorAccentColor: '#000', - - // `'BEAM'` for |, `'UNDERLINE'` for _, `'BLOCK'` for █ - cursorShape: 'BLOCK', - - // set to `true` (without backticks and without quotes) for blinking cursor - cursorBlink: false, - - // color of the text - foregroundColor: '#fff', - - // terminal background color - // opacity is only supported on macOS - backgroundColor: '#000', - - // terminal selection color - selectionColor: 'rgba(248,28,229,0.3)', - - // border color (window, tabs) - borderColor: '#333', - - // custom CSS to embed in the main window - css: '', - - // custom CSS to embed in the terminal window - termCSS: '', - - // if you're using a Linux setup which show native menus, set to false - // default: `true` on Linux, `true` on Windows, ignored on macOS - showHamburgerMenu: '', - - // set to `false` (without backticks and without quotes) if you want to hide the minimize, maximize and close buttons - // additionally, set to `'left'` if you want them on the left, like in Ubuntu - // default: `true` (without backticks and without quotes) on Windows and Linux, ignored on macOS - showWindowControls: '', - - // custom padding (CSS format, i.e.: `top right bottom left`) - padding: '12px 14px', - - // the full list. if you're going to provide the full color palette, - // including the 6 x 6 color cubes and the grayscale map, just provide - // an array here instead of a color map object - 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', - }, - - // the shell to run when spawning a new session (i.e. /usr/local/bin/fish) - // if left empty, your system's login shell will be used by default - // - // Windows - // - Make sure to use a full path if the binary name doesn't work - // - Remove `--login` in shellArgs - // - // Bash on Windows - // - Example: `C:\\Windows\\System32\\bash.exe` - // - // PowerShell on Windows - // - Example: `C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` - shell: '', - - // for setting shell arguments (i.e. for using interactive shellArgs: `['-i']`) - // by default `['--login']` will be used - shellArgs: ['--login'], - - // for environment variables - env: {}, - - // set to `false` for no bell - bell: 'SOUND', - - // if `true` (without backticks and without quotes), selected text will automatically be copied to the clipboard - copyOnSelect: false, - - // if `true` (without backticks and without quotes), hyper will be set as the default protocol client for SSH - defaultSSHApp: true, - - // if `true` (without backticks and without quotes), on right click selected text will be copied or pasted if no - // selection is present (`true` by default on Windows and disables the context menu feature) - // quickEdit: true, - - // URL to custom bell - // bellSoundURL: 'http://example.com/bell.mp3', - - // for advanced config flags please refer to https://hyper.is/#cfg - }, - - // a list of plugins to fetch and install from npm - // format: [@org/]project[#version] - // examples: - // `hyperpower` - // `@company/project` - // `project#1.0.1` - plugins: [], - - // in development, you can create a directory under - // `~/.hyper_plugins/local/` and include it here - // to load it and avoid it being `npm install`ed - localPlugins: [], - - keymaps: { - // Example - // 'window:devtools': 'cmd+alt+o', - }, -}; diff --git a/app/config/config-default.json b/app/config/config-default.json new file mode 100644 index 00000000..2a6a66ff --- /dev/null +++ b/app/config/config-default.json @@ -0,0 +1,77 @@ +{ + "$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": {} +} diff --git a/app/config/import.js b/app/config/import.js deleted file mode 100644 index 58394457..00000000 --- a/app/config/import.js +++ /dev/null @@ -1,62 +0,0 @@ -const {writeFileSync, readFileSync} = require('fs'); -const {sync: mkdirpSync} = require('mkdirp'); -const {defaultCfg, cfgPath, plugs, defaultPlatformKeyPath} = require('./paths'); -const {_init, _extractDefault} = require('./init'); - -let defaultConfig; - -const _write = function(path, data) { - // 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 = function(str) { - return str.replace(/\r?\n/g, '\r\n'); - }; - const format = process.platform === 'win32' ? crlfify(data.toString()) : data; - writeFileSync(path, format, 'utf8'); -}; - -const _importConf = function() { - // init plugin directories if not present - mkdirpSync(plugs.base); - mkdirpSync(plugs.local); - - try { - const defaultCfgRaw = readFileSync(defaultCfg, 'utf8'); - const _defaultCfg = _extractDefault(defaultCfgRaw); - // Importing platform specific keymap - try { - const content = readFileSync(defaultPlatformKeyPath(), 'utf8'); - const mapping = JSON.parse(content); - _defaultCfg.keymaps = mapping; - } catch (err) { - //eslint-disable-next-line no-console - console.error(err); - } - // Importing user config - try { - const _cfgPath = readFileSync(cfgPath, 'utf8'); - return {userCfg: _cfgPath, defaultCfg: _defaultCfg}; - } catch (err) { - _write(cfgPath, defaultCfgRaw); - return {userCfg: defaultCfgRaw, defaultCfg: _defaultCfg}; - } - } catch (err) { - //eslint-disable-next-line no-console - console.log(err); - } -}; - -exports._import = () => { - const imported = _importConf(); - defaultConfig = imported.defaultCfg; - const result = _init(imported); - return result; -}; - -exports.getDefaultConfig = () => { - if (!defaultConfig) { - defaultConfig = _extractDefault(_importConf().defaultCfg); - } - return defaultConfig; -}; diff --git a/app/config/import.ts b/app/config/import.ts new file mode 100644 index 00000000..b9965375 --- /dev/null +++ b/app/config/import.ts @@ -0,0 +1,65 @@ +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; + _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; +}; diff --git a/app/config/init.js b/app/config/init.js deleted file mode 100644 index 80373b92..00000000 --- a/app/config/init.js +++ /dev/null @@ -1,48 +0,0 @@ -const vm = require('vm'); -const notify = require('../notify'); -const mapKeys = require('../utils/map-keys'); - -const _extract = function(script) { - const module = {}; - script.runInNewContext({module}); - if (!module.exports) { - throw new Error('Error reading configuration: `module.exports` not set'); - } - return module.exports; -}; - -const _syntaxValidation = function(cfg) { - try { - return new vm.Script(cfg, {filename: '.hyper.js', displayErrors: true}); - } catch (err) { - notify('Error loading config:', `${err.name}, see DevTools for more info`, {error: err}); - } -}; - -const _extractDefault = function(cfg) { - return _extract(_syntaxValidation(cfg)); -}; - -// init config -const _init = function(cfg) { - const script = _syntaxValidation(cfg.userCfg); - if (script) { - const _cfg = _extract(script); - if (!_cfg.config) { - notify('Error reading configuration: `config` key is missing'); - return cfg.defaultCfg; - } - // Merging platform specific keymaps with user defined keymaps - _cfg.keymaps = mapKeys(Object.assign({}, cfg.defaultCfg.keymaps, _cfg.keymaps)); - // Ignore undefined values in plugin and localPlugins array Issue #1862 - _cfg.plugins = (_cfg.plugins && _cfg.plugins.filter(Boolean)) || []; - _cfg.localPlugins = (_cfg.localPlugins && _cfg.localPlugins.filter(Boolean)) || []; - return _cfg; - } - return cfg.defaultCfg; -}; - -module.exports = { - _init, - _extractDefault -}; diff --git a/app/config/init.ts b/app/config/init.ts new file mode 100644 index 00000000..a41b864c --- /dev/null +++ b/app/config/init.ts @@ -0,0 +1,63 @@ +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 => { + const module: Record = {}; + 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}; diff --git a/app/config/migrate.ts b/app/config/migrate.ts new file mode 100644 index 00000000..522da1e9 --- /dev/null +++ b/app/config/migrate.ts @@ -0,0 +1,190 @@ +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}`); +}; diff --git a/app/config/open.js b/app/config/open.js deleted file mode 100644 index 1e15a58f..00000000 --- a/app/config/open.js +++ /dev/null @@ -1,77 +0,0 @@ -const {shell} = require('electron'); -const {cfgPath} = require('./paths'); - -module.exports = () => Promise.resolve(shell.openItem(cfgPath)); - -// 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') { - const Registry = require('winreg'); - const {exec} = require('child_process'); - - const getUserChoiceKey = async () => { - // Load FileExts keys for .js files - const keys = await new Promise((resolve, reject) => { - new Registry({ - hive: Registry.HKCU, - key: '\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\.js' - }).keys((error, items) => { - if (error) { - reject(error); - } else { - resolve(items || []); - } - }); - }); - - // Find UserChoice key - const userChoice = keys.find(k => k.key.endsWith('UserChoice')); - return userChoice; - }; - - const hasDefaultSet = async () => { - let userChoice = await getUserChoiceKey(); - if (!userChoice) return false; - - // Load key values - let values = await new Promise((resolve, reject) => { - userChoice.values((error, items) => { - if (error) { - reject(error); - } - resolve(items.map(item => item.value || '') || []); - }); - }); - - // Look for default program - const hasDefaultProgramConfigured = values.every( - value => value && typeof value === 'string' && !value.includes('WScript.exe') && !value.includes('JSFile') - ); - - return hasDefaultProgramConfigured; - }; - - // This mimics shell.openItem, true if it worked, false if not. - const openNotepad = file => - new Promise(resolve => { - exec(`start notepad.exe ${file}`, error => { - resolve(!error); - }); - }); - - module.exports = () => - hasDefaultSet() - .then(yes => { - if (yes) { - return shell.openItem(cfgPath); - } - //eslint-disable-next-line no-console - console.warn('No default app set for .js files, using notepad.exe fallback'); - return openNotepad(cfgPath); - }) - .catch(err => { - //eslint-disable-next-line no-console - console.error('Open config with default app error:', err); - return openNotepad(cfgPath); - }); -} diff --git a/app/config/open.ts b/app/config/open.ts new file mode 100644 index 00000000..264c2292 --- /dev/null +++ b/app/config/open.ts @@ -0,0 +1,80 @@ +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((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; diff --git a/app/config/paths.js b/app/config/paths.ts similarity index 54% rename from app/config/paths.js rename to app/config/paths.ts index 5afb3d64..2c2ce283 100644 --- a/app/config/paths.js +++ b/app/config/paths.ts @@ -1,15 +1,36 @@ // This module exports paths, names, and other metadata that is referenced -const {homedir} = require('os'); -const {statSync} = require('fs'); -const {resolve, join} = require('path'); -const isDev = require('electron-is-dev'); +import {statSync} from 'fs'; +import {homedir} from 'os'; +import {resolve, join} from 'path'; -const cfgFile = '.hyper.js'; -const defaultCfgFile = 'config-default.js'; -const homeDir = homedir(); +import {app} from 'electron'; -let cfgPath = join(homeDir, cfgFile); -let cfgDir = homeDir; +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); @@ -21,14 +42,13 @@ if (isDev) { statSync(devCfg); cfgPath = devCfg; cfgDir = devDir; - //eslint-disable-next-line no-console console.log('using config file:', cfgPath); } catch (err) { // ignore } } -const plugins = resolve(cfgDir, '.hyper_plugins'); +const plugins = resolve(cfgDir, 'plugins'); const plugs = { base: plugins, local: resolve(plugins, 'local'), @@ -58,9 +78,10 @@ const defaultPlatformKeyPath = () => { } }; -module.exports = { +export { cfgDir, cfgPath, + legacyCfgPath, cfgFile, defaultCfg, icon, @@ -68,5 +89,8 @@ module.exports = { plugs, yarn, cliScriptPath, - cliLinkPath + cliLinkPath, + homeDirectory, + schemaFile, + schemaPath }; diff --git a/app/config/schema.json b/app/config/schema.json new file mode 100644 index 00000000..6bcf8500 --- /dev/null +++ b/app/config/schema.json @@ -0,0 +1,756 @@ +{ + "$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": { + "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", + "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" +} + diff --git a/app/config/windows.js b/app/config/windows.js deleted file mode 100644 index 901f47b2..00000000 --- a/app/config/windows.js +++ /dev/null @@ -1,22 +0,0 @@ -const Config = require('electron-config'); - -const defaults = { - windowPosition: [50, 50], - windowSize: [540, 380] -}; - -// local storage -const cfg = new Config({defaults}); - -module.exports = { - defaults, - get() { - const position = cfg.get('windowPosition'); - const size = cfg.get('windowSize'); - return {position, size}; - }, - recordState(win) { - cfg.set('windowPosition', win.getPosition()); - cfg.set('windowSize', win.getSize()); - } -}; diff --git a/app/config/windows.ts b/app/config/windows.ts new file mode 100644 index 00000000..4cd6d500 --- /dev/null +++ b/app/config/windows.ts @@ -0,0 +1,21 @@ +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()); +} diff --git a/app/index.html b/app/index.html index 04a2a4fa..3d16602e 100644 --- a/app/index.html +++ b/app/index.html @@ -5,6 +5,7 @@ + - - ); - } -} diff --git a/lib/components/header.tsx b/lib/components/header.tsx new file mode 100644 index 00000000..416ecbc5 --- /dev/null +++ b/lib/components/header.tsx @@ -0,0 +1,260 @@ +import React, {forwardRef, useState} from 'react'; + +import type {HeaderProps} from '../../typings/hyper'; +import {decorate, getTabsProps} from '../utils/plugins'; + +import Tabs_ from './tabs'; + +const Tabs = decorate(Tabs_, 'Tabs'); + +const Header = forwardRef((props, ref) => { + const [headerMouseDownWindowX, setHeaderMouseDownWindowX] = useState(0); + const [headerMouseDownWindowY, setHeaderMouseDownWindowY] = useState(0); + + const onChangeIntent = (active: string) => { + // we ignore clicks if they're a byproduct of a drag + // motion to move the window + if (window.screenX !== headerMouseDownWindowX || window.screenY !== headerMouseDownWindowY) { + return; + } + + props.onChangeTab(active); + }; + + const handleHeaderMouseDown = () => { + // the hack of all hacks, this prevents the term + // iframe from losing focus, for example, when + // the user drags the nav around + // Fixed by calling window.focusActiveTerm(), thus we can support drag tab + // ev.preventDefault(); + + // persist start positions of a potential drag motion + // to differentiate dragging from clicking + setHeaderMouseDownWindowX(window.screenX); + setHeaderMouseDownWindowY(window.screenY); + }; + + const handleHamburgerMenuClick = (event: React.MouseEvent) => { + let {right: x, bottom: y} = event.currentTarget.getBoundingClientRect(); + x -= 15; // to compensate padding + y -= 12; // ^ same + props.openHamburgerMenu({x, y}); + }; + + const handleMaximizeClick = () => { + if (props.maximized) { + props.unmaximize(); + } else { + props.maximize(); + } + }; + + const handleMinimizeClick = () => { + props.minimize(); + }; + + const handleCloseClick = () => { + props.close(); + }; + + const getWindowHeaderConfig = () => { + const {showHamburgerMenu, showWindowControls} = props; + + const defaults = { + hambMenu: !props.isMac, // show by default on windows and linux + winCtrls: !props.isMac // show by default on Windows and Linux + }; + + // don't allow the user to change defaults on macOS + if (props.isMac) { + return defaults; + } + + return { + hambMenu: showHamburgerMenu === '' ? defaults.hambMenu : showHamburgerMenu, + winCtrls: showWindowControls === '' ? defaults.winCtrls : showWindowControls + }; + }; + + const {isMac} = props; + const {borderColor} = props; + let title = 'Hyper'; + if (props.tabs.length === 1 && props.tabs[0].title) { + // if there's only one tab we use its title as the window title + title = props.tabs[0].title; + } + const {hambMenu, winCtrls} = getWindowHeaderConfig(); + const left = winCtrls === 'left'; + const maxButtonHref = props.maximized + ? './renderer/assets/icons.svg#restore-window' + : './renderer/assets/icons.svg#maximize-window'; + + return ( +
window.focusActiveTerm()} + onDoubleClick={handleMaximizeClick} + ref={ref} + > + {!isMac && ( +
1 ? 'header_windowHeaderWithBorder' : ''}`} + style={{borderColor}} + > + {hambMenu && ( + + + + )} + {title} + {winCtrls && ( +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ )} +
+ )} + {props.customChildrenBefore} + + {props.customChildren} + + +
+ ); +}); + +Header.displayName = 'Header'; + +export default Header; diff --git a/lib/components/new-tab.tsx b/lib/components/new-tab.tsx new file mode 100644 index 00000000..3fe99543 --- /dev/null +++ b/lib/components/new-tab.tsx @@ -0,0 +1,149 @@ +import React, {useRef, useState} from 'react'; + +import {VscChevronDown} from '@react-icons/all-files/vsc/VscChevronDown'; +import useClickAway from 'react-use/lib/useClickAway'; + +import type {configOptions} from '../../typings/config'; + +interface Props { + defaultProfile: string; + profiles: configOptions['profiles']; + openNewTab: (name: string) => void; + backgroundColor: string; + borderColor: string; + tabsVisible: boolean; +} +const isMac = /Mac/.test(navigator.userAgent); + +const DropdownButton = ({defaultProfile, profiles, openNewTab, backgroundColor, borderColor, tabsVisible}: Props) => { + const [dropdownOpen, setDropdownOpen] = useState(false); + const ref = useRef(null); + + const toggleDropdown = () => { + setDropdownOpen(!dropdownOpen); + }; + + useClickAway(ref, () => { + setDropdownOpen(false); + }); + + return ( +
e.stopPropagation()} + onBlur={() => setDropdownOpen(false)} + > + + + {dropdownOpen && ( +
    + {profiles.map((profile) => ( +
  • { + openNewTab(profile.name); + setDropdownOpen(false); + }} + className={`profile_dropdown_item ${ + profile.name === defaultProfile && profiles.length > 1 ? 'profile_dropdown_item_default' : '' + }`} + > + {profile.name} +
  • + ))} +
+ )} + + +
+ ); +}; + +export default DropdownButton; diff --git a/lib/components/notification.js b/lib/components/notification.js deleted file mode 100644 index 0d5b6ffb..00000000 --- a/lib/components/notification.js +++ /dev/null @@ -1,115 +0,0 @@ -import React from 'react'; - -export default class Notification extends React.PureComponent { - constructor() { - super(); - this.state = { - dismissing: false - }; - this.handleDismiss = this.handleDismiss.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 reset the timer - if (next.text !== this.props.text) { - if (this.props.dismissAfter) { - this.resetDismissTimer(); - } - if (this.state.dismissing) { - this.setState({dismissing: false}); - } - } - } - - handleDismiss() { - this.setState({dismissing: true}); - } - - onElement(el) { - if (el) { - el.addEventListener('webkitTransitionEnd', () => { - if (this.state.dismissing) { - this.props.onDismiss(); - } - }); - const {backgroundColor} = this.props; - if (backgroundColor) { - el.style.setProperty('background-color', backgroundColor, 'important'); - } - } - } - - setDismissTimer() { - this.dismissTimer = setTimeout(() => { - this.handleDismiss(); - }, this.props.dismissAfter); - } - - resetDismissTimer() { - clearTimeout(this.dismissTimer); - this.setDismissTimer(); - } - - componentWillUnmount() { - clearTimeout(this.dismissTimer); - } - - render() { - const {backgroundColor, color} = this.props; - const opacity = this.state.dismissing ? 0 : 1; - return ( -
- {this.props.customChildrenBefore} - {this.props.children || this.props.text} - {this.props.userDismissable ? ( - - [x] - - ) : null} - {this.props.customChildren} - - -
- ); - } -} diff --git a/lib/components/notification.tsx b/lib/components/notification.tsx new file mode 100644 index 00000000..68b7a2df --- /dev/null +++ b/lib/components/notification.tsx @@ -0,0 +1,110 @@ +import React, {forwardRef, useEffect, useRef, useState} from 'react'; + +import type {NotificationProps} from '../../typings/hyper'; + +const Notification = forwardRef>((props, ref) => { + const dismissTimer = useRef(undefined); + const [dismissing, setDismissing] = useState(false); + + useEffect(() => { + setDismissTimer(); + }, []); + + useEffect(() => { + // if we have a timer going and the notification text + // changed we reset the timer + resetDismissTimer(); + setDismissing(false); + }, [props.text]); + + const handleDismiss = () => { + setDismissing(true); + }; + + const onElement = (el: HTMLDivElement | null) => { + if (el) { + el.addEventListener('webkitTransitionEnd', () => { + if (dismissing) { + props.onDismiss(); + } + }); + const {backgroundColor} = props; + if (backgroundColor) { + el.style.setProperty('background-color', backgroundColor, 'important'); + } + + if (ref) { + if (typeof ref === 'function') ref(el); + else ref.current = el; + } + } + }; + + const setDismissTimer = () => { + if (typeof props.dismissAfter === 'number') { + dismissTimer.current = setTimeout(() => { + handleDismiss(); + }, props.dismissAfter); + } + }; + + const resetDismissTimer = () => { + clearTimeout(dismissTimer.current); + setDismissTimer(); + }; + + useEffect(() => { + return () => { + clearTimeout(dismissTimer.current); + }; + }, []); + + const {backgroundColor, color} = props; + const opacity = dismissing ? 0 : 1; + return ( +
+ {props.customChildrenBefore} + {props.children || props.text} + {props.userDismissable ? ( + + [x] + + ) : null} + {props.customChildren} + + +
+ ); +}); + +Notification.displayName = 'Notification'; + +export default Notification; diff --git a/lib/components/notifications.js b/lib/components/notifications.js deleted file mode 100644 index 80ddf735..00000000 --- a/lib/components/notifications.js +++ /dev/null @@ -1,128 +0,0 @@ -import React from 'react'; - -import {decorate} from '../utils/plugins'; - -import Notification_ from './notification'; - -const Notification = decorate(Notification_, 'Notification'); - -export default class Notifications extends React.PureComponent { - render() { - return ( -
- {this.props.customChildrenBefore} - {this.props.fontShowing && ( - - )} - - {this.props.resizeShowing && ( - - )} - - {this.props.messageShowing && ( - - {this.props.messageURL - ? [ - this.props.messageText, - ' (', - { - window.require('electron').shell.openExternal(ev.target.href); - ev.preventDefault(); - }} - href={this.props.messageURL} - > - more - , - ')' - ] - : null} - - )} - - {this.props.updateShowing && ( - - Version {this.props.updateVersion} ready. - {this.props.updateNote && ` ${this.props.updateNote.trim().replace(/\.$/, '')}`} ( { - window.require('electron').shell.openExternal(ev.target.href); - ev.preventDefault(); - }} - href={`https://github.com/zeit/hyper/releases/tag/${this.props.updateVersion}`} - > - notes - ).{' '} - {this.props.updateCanInstall ? ( - - Restart - - ) : ( - { - window.require('electron').shell.openExternal(ev.target.href); - ev.preventDefault(); - }} - href={this.props.updateReleaseUrl} - > - Download - - )}.{' '} - - )} - {this.props.customChildren} - - -
- ); - } -} diff --git a/lib/components/notifications.tsx b/lib/components/notifications.tsx new file mode 100644 index 00000000..79e6ede3 --- /dev/null +++ b/lib/components/notifications.tsx @@ -0,0 +1,132 @@ +import React, {forwardRef} from 'react'; + +import type {NotificationsProps} from '../../typings/hyper'; +import {decorate} from '../utils/plugins'; + +import Notification_ from './notification'; + +const Notification = decorate(Notification_, 'Notification'); + +const Notifications = forwardRef((props, ref) => { + return ( +
+ {props.customChildrenBefore} + {props.fontShowing && ( + + )} + + {props.resizeShowing && ( + + )} + + {props.messageShowing && ( + + {props.messageURL ? ( + <> + {props.messageText} ( + { + void window.require('electron').shell.openExternal(ev.currentTarget.href); + ev.preventDefault(); + }} + href={props.messageURL} + > + more + + ) + + ) : null} + + )} + + {props.updateShowing && ( + + Version {props.updateVersion} ready. + {props.updateNote && ` ${props.updateNote.trim().replace(/\.$/, '')}`} ( + { + void window.require('electron').shell.openExternal(ev.currentTarget.href); + ev.preventDefault(); + }} + href={`https://github.com/vercel/hyper/releases/tag/${props.updateVersion}`} + > + notes + + ).{' '} + {props.updateCanInstall ? ( + + Restart + + ) : ( + { + void window.require('electron').shell.openExternal(ev.currentTarget.href); + ev.preventDefault(); + }} + href={props.updateReleaseUrl!} + > + Download + + )} + .{' '} + + )} + {props.customChildren} + + +
+ ); +}); + +Notifications.displayName = 'Notifications'; + +export default Notifications; diff --git a/lib/components/searchBox.tsx b/lib/components/searchBox.tsx new file mode 100644 index 00000000..55a72f16 --- /dev/null +++ b/lib/components/searchBox.tsx @@ -0,0 +1,237 @@ +import React, {useCallback, useRef, useEffect, forwardRef} from 'react'; + +import {VscArrowDown} from '@react-icons/all-files/vsc/VscArrowDown'; +import {VscArrowUp} from '@react-icons/all-files/vsc/VscArrowUp'; +import {VscCaseSensitive} from '@react-icons/all-files/vsc/VscCaseSensitive'; +import {VscClose} from '@react-icons/all-files/vsc/VscClose'; +import {VscRegex} from '@react-icons/all-files/vsc/VscRegex'; +import {VscWholeWord} from '@react-icons/all-files/vsc/VscWholeWord'; +import clsx from 'clsx'; + +import type {SearchBoxProps} from '../../typings/hyper'; + +type SearchButtonColors = { + foregroundColor: string; + selectionColor: string; + backgroundColor: string; +}; + +type SearchButtonProps = React.PropsWithChildren< + { + onClick: () => void; + active: boolean; + title: string; + } & SearchButtonColors +>; + +const SearchButton = ({ + onClick, + active, + title, + foregroundColor, + backgroundColor, + selectionColor, + children +}: SearchButtonProps) => { + const handleKeyUp = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + onClick(); + } + }, + [onClick] + ); + + return ( +
+ {children} + +
+ ); +}; + +const SearchBox = forwardRef((props, ref) => { + const { + caseSensitive, + wholeWord, + regex, + results, + toggleCaseSensitive, + toggleWholeWord, + toggleRegex, + next, + prev, + close, + backgroundColor, + foregroundColor, + borderColor, + selectionColor, + font + } = props; + + const searchTermRef = useRef(''); + const inputRef = useRef(null); + + const handleChange = useCallback( + (event: React.KeyboardEvent) => { + searchTermRef.current = event.currentTarget.value; + if (event.shiftKey && event.key === 'Enter') { + prev(searchTermRef.current); + } else if (event.key === 'Enter') { + next(searchTermRef.current); + } + }, + [prev, next] + ); + + useEffect(() => { + inputRef.current?.focus(); + }, [inputRef.current]); + + const searchButtonColors: SearchButtonColors = { + backgroundColor: borderColor, + selectionColor, + foregroundColor + }; + + return ( +
+
+ + + + + + + + + + + + + +
+ + + {results === undefined + ? '' + : results.resultCount === 0 + ? 'No results' + : `${results.resultIndex + 1} of ${results.resultCount}`} + + +
+ prev(searchTermRef.current)} + active={false} + title="Previous Match" + {...searchButtonColors} + > + + + + next(searchTermRef.current)} + active={false} + title="Next Match" + {...searchButtonColors} + > + + + + + + +
+ + +
+ ); +}); + +SearchBox.displayName = 'SearchBox'; + +export default SearchBox; diff --git a/lib/components/split-pane.js b/lib/components/split-pane.js deleted file mode 100644 index 6f41fdc8..00000000 --- a/lib/components/split-pane.js +++ /dev/null @@ -1,215 +0,0 @@ -/* eslint-disable quote-props */ -import React from 'react'; -import _ from 'lodash'; - -export default class SplitPane extends React.PureComponent { - constructor(props) { - super(props); - this.handleDragStart = this.handleDragStart.bind(this); - this.handleAutoResize = this.handleAutoResize.bind(this); - this.onDrag = this.onDrag.bind(this); - this.onDragEnd = this.onDragEnd.bind(this); - this.state = {dragging: false}; - } - - componentDidUpdate(prevProps) { - if (this.state.dragging && prevProps.sizes !== this.props.sizes) { - // recompute positions for ongoing dragging - this.dragPanePosition = this.dragTarget.getBoundingClientRect()[this.d2]; - } - } - - setupPanes(ev) { - this.panes = Array.from(ev.target.parentNode.childNodes); - this.paneIndex = this.panes.indexOf(ev.target); - this.paneIndex -= Math.ceil(this.paneIndex / 2); - } - - handleAutoResize(ev) { - ev.preventDefault(); - - this.setupPanes(ev); - - const sizes_ = this.getSizes(); - sizes_[this.paneIndex] = 0; - sizes_[this.paneIndex + 1] = 0; - - const availableWidth = 1 - _.sum(sizes_); - sizes_[this.paneIndex] = availableWidth / 2; - sizes_[this.paneIndex + 1] = availableWidth / 2; - - this.props.onResize(sizes_); - } - - handleDragStart(ev) { - ev.preventDefault(); - this.setState({dragging: true}); - window.addEventListener('mousemove', this.onDrag); - window.addEventListener('mouseup', this.onDragEnd); - - // dimensions to consider - if (this.props.direction === 'horizontal') { - this.d1 = 'height'; - this.d2 = 'top'; - this.d3 = 'clientY'; - } else { - this.d1 = 'width'; - this.d2 = 'left'; - this.d3 = 'clientX'; - } - - this.dragTarget = ev.target; - this.dragPanePosition = this.dragTarget.getBoundingClientRect()[this.d2]; - this.panesSize = ev.target.parentNode.getBoundingClientRect()[this.d1]; - this.setupPanes(ev); - } - - getSizes() { - const {sizes} = this.props; - let sizes_; - - if (sizes) { - sizes_ = [].concat(sizes); - } else { - const total = this.props.children.length; - const count = new Array(total).fill(1 / total); - - sizes_ = count; - } - return sizes_; - } - - onDrag(ev) { - const sizes_ = this.getSizes(); - - const i = this.paneIndex; - const pos = ev[this.d3]; - const d = Math.abs(this.dragPanePosition - pos) / this.panesSize; - if (pos > this.dragPanePosition) { - sizes_[i] += d; - sizes_[i + 1] -= d; - } else { - sizes_[i] -= d; - sizes_[i + 1] += d; - } - this.props.onResize(sizes_); - } - - onDragEnd() { - if (this.state.dragging) { - window.removeEventListener('mousemove', this.onDrag); - window.removeEventListener('mouseup', this.onDragEnd); - this.setState({dragging: false}); - } - } - - render() { - const children = this.props.children; - const {direction, borderColor} = this.props; - const sizeProperty = direction === 'horizontal' ? 'height' : 'width'; - let {sizes} = this.props; - if (!sizes) { - // workaround for the fact that if we don't specify - // sizes, sometimes flex fails to calculate the - // right height for the horizontal panes - sizes = new Array(children.length).fill(1 / children.length); - } - return ( -
- {React.Children.map(children, (child, i) => { - const style = { - // flexBasis doesn't work for the first horizontal pane, height need to be specified - [sizeProperty]: sizes[i] * 100 + '%', - flexBasis: sizes[i] * 100 + '%', - flexGrow: 0 - }; - return [ -
- {child} -
, - i < children.length - 1 ? ( -
- ) : null - ]; - })} -
- - -
- ); - } - - componentWillUnmount() { - // ensure drag end - if (this.dragging) { - this.onDragEnd(); - } - } -} diff --git a/lib/components/split-pane.tsx b/lib/components/split-pane.tsx new file mode 100644 index 00000000..aea53f6f --- /dev/null +++ b/lib/components/split-pane.tsx @@ -0,0 +1,191 @@ +import React, {useState, useEffect, useRef, forwardRef} from 'react'; + +import sum from 'lodash/sum'; + +import type {SplitPaneProps} from '../../typings/hyper'; + +const SplitPane = forwardRef((props, ref) => { + const dragPanePosition = useRef(0); + const dragTarget = useRef(null); + const paneIndex = useRef(0); + const d1 = props.direction === 'horizontal' ? 'height' : 'width'; + const d2 = props.direction === 'horizontal' ? 'top' : 'left'; + const d3 = props.direction === 'horizontal' ? 'clientY' : 'clientX'; + const panesSize = useRef(null); + const [dragging, setDragging] = useState(false); + + const handleAutoResize = (ev: React.MouseEvent, index: number) => { + ev.preventDefault(); + + paneIndex.current = index; + + const sizes_ = getSizes(); + sizes_[paneIndex.current] = 0; + sizes_[paneIndex.current + 1] = 0; + + const availableWidth = 1 - sum(sizes_); + sizes_[paneIndex.current] = availableWidth / 2; + sizes_[paneIndex.current + 1] = availableWidth / 2; + + props.onResize(sizes_); + }; + + const handleDragStart = (ev: React.MouseEvent, index: number) => { + ev.preventDefault(); + setDragging(true); + window.addEventListener('mousemove', onDrag); + window.addEventListener('mouseup', onDragEnd); + + const target = ev.target as HTMLDivElement; + dragTarget.current = target; + dragPanePosition.current = dragTarget.current.getBoundingClientRect()[d2]; + panesSize.current = target.parentElement!.getBoundingClientRect()[d1]; + paneIndex.current = index; + }; + + const getSizes = () => { + const {sizes} = props; + let sizes_: number[]; + + if (sizes) { + sizes_ = [...sizes.asMutable()]; + } else { + const total = props.children.length; + const count = new Array(total).fill(1 / total); + + sizes_ = count; + } + return sizes_; + }; + + const onDrag = (ev: MouseEvent) => { + const sizes_ = getSizes(); + + const i = paneIndex.current; + const pos = ev[d3]; + const d = Math.abs(dragPanePosition.current - pos) / panesSize.current!; + if (pos > dragPanePosition.current) { + sizes_[i] += d; + sizes_[i + 1] -= d; + } else { + sizes_[i] -= d; + sizes_[i + 1] += d; + } + props.onResize(sizes_); + }; + + const onDragEnd = () => { + window.removeEventListener('mousemove', onDrag); + window.removeEventListener('mouseup', onDragEnd); + setDragging(false); + }; + + useEffect(() => { + return () => { + onDragEnd(); + }; + }, []); + + const {children, direction, borderColor} = props; + const sizeProperty = direction === 'horizontal' ? 'height' : 'width'; + // workaround for the fact that if we don't specify + // sizes, sometimes flex fails to calculate the + // right height for the horizontal panes + const sizes = props.sizes || new Array(children.length).fill(1 / children.length); + return ( +
+ {children.map((child, i) => { + const style = { + // flexBasis doesn't work for the first horizontal pane, height need to be specified + [sizeProperty]: `${sizes[i] * 100}%`, + flexBasis: `${sizes[i] * 100}%`, + flexGrow: 0 + }; + + return ( + +
+ {child} +
+ {i < children.length - 1 ? ( +
handleDragStart(e, i)} + onDoubleClick={(e) => handleAutoResize(e, i)} + style={{backgroundColor: borderColor}} + className={`splitpane_divider splitpane_divider_${direction}`} + /> + ) : null} + + ); + })} +
+ + +
+ ); +}); + +SplitPane.displayName = 'SplitPane'; + +export default SplitPane; diff --git a/lib/components/style-sheet.js b/lib/components/style-sheet.js deleted file mode 100644 index 1dc02b72..00000000 --- a/lib/components/style-sheet.js +++ /dev/null @@ -1,153 +0,0 @@ -import React from 'react'; - -export default class StyleSheet extends React.PureComponent { - render() { - const {backgroundColor, fontFamily, foregroundColor, borderColor} = this.props; - - return ( - - ); - } -} diff --git a/lib/components/style-sheet.tsx b/lib/components/style-sheet.tsx new file mode 100644 index 00000000..86d89424 --- /dev/null +++ b/lib/components/style-sheet.tsx @@ -0,0 +1,27 @@ +import React, {forwardRef} from 'react'; + +import type {StyleSheetProps} from '../../typings/hyper'; + +const StyleSheet = forwardRef((props, ref) => { + const {borderColor} = props; + + return ( + + ); +}); + +StyleSheet.displayName = 'StyleSheet'; + +export default StyleSheet; diff --git a/lib/components/tab.js b/lib/components/tab.js deleted file mode 100644 index 33c31539..00000000 --- a/lib/components/tab.js +++ /dev/null @@ -1,180 +0,0 @@ -import React from 'react'; - -export default class Tab extends React.PureComponent { - constructor() { - super(); - - this.handleHover = this.handleHover.bind(this); - this.handleBlur = this.handleBlur.bind(this); - this.handleClick = this.handleClick.bind(this); - - this.state = { - hovered: false - }; - } - - handleHover() { - this.setState({ - hovered: true - }); - } - - handleBlur() { - this.setState({ - hovered: false - }); - } - - handleClick(event) { - const isLeftClick = event.nativeEvent.which === 1; - const isMiddleClick = event.nativeEvent.which === 2; - - if (isLeftClick && !this.props.isActive) { - this.props.onSelect(); - } else if (isMiddleClick) { - this.props.onClose(); - } - } - - render() { - const {isActive, isFirst, isLast, borderColor, hasActivity} = this.props; - const {hovered} = this.state; - - return ( - -
  • - {this.props.customChildrenBefore} - - - {this.props.text} - - - - - - - - {this.props.customChildren} -
  • - - -
    - ); - } -} diff --git a/lib/components/tab.tsx b/lib/components/tab.tsx new file mode 100644 index 00000000..c977c039 --- /dev/null +++ b/lib/components/tab.tsx @@ -0,0 +1,168 @@ +import React, {forwardRef} from 'react'; + +import type {TabProps} from '../../typings/hyper'; + +const Tab = forwardRef((props, ref) => { + const handleClick = (event: React.MouseEvent) => { + const isLeftClick = event.nativeEvent.which === 1; + + if (isLeftClick && !props.isActive) { + props.onSelect(); + } + }; + + const handleMouseUp = (event: React.MouseEvent) => { + const isMiddleClick = event.nativeEvent.which === 2; + + if (isMiddleClick) { + props.onClose(); + } + }; + + const {isActive, isFirst, isLast, borderColor, hasActivity} = props; + + return ( + <> +
  • + {props.customChildrenBefore} + + + {props.text} + + + + + + + + {props.customChildren} +
  • + + + + ); +}); + +Tab.displayName = 'Tab'; + +export default Tab; diff --git a/lib/components/tabs.js b/lib/components/tabs.js deleted file mode 100644 index 1d411fac..00000000 --- a/lib/components/tabs.js +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; - -import {decorate, getTabProps} from '../utils/plugins'; - -import Tab_ from './tab'; - -const Tab = decorate(Tab_, 'Tab'); -const isMac = /Mac/.test(navigator.userAgent); - -export default class Tabs extends React.PureComponent { - render() { - const {tabs = [], borderColor, onChange, onClose} = this.props; - - const hide = !isMac && tabs.length === 1; - - return ( - - ); - } -} diff --git a/lib/components/tabs.tsx b/lib/components/tabs.tsx new file mode 100644 index 00000000..49043e7d --- /dev/null +++ b/lib/components/tabs.tsx @@ -0,0 +1,113 @@ +import React, {forwardRef} from 'react'; + +import type {TabsProps} from '../../typings/hyper'; +import {decorate, getTabProps} from '../utils/plugins'; + +import DropdownButton from './new-tab'; +import Tab_ from './tab'; + +const Tab = decorate(Tab_, 'Tab'); +const isMac = /Mac/.test(navigator.userAgent); + +const Tabs = forwardRef((props, ref) => { + const {tabs = [], borderColor, onChange, onClose, fullScreen} = props; + + const hide = !isMac && tabs.length === 1; + + return ( + + ); +}); + +Tabs.displayName = 'Tabs'; + +export default Tabs; diff --git a/lib/components/term-group.js b/lib/components/term-group.tsx similarity index 60% rename from lib/components/term-group.js rename to lib/components/term-group.tsx index 328f959b..5e6e66ec 100644 --- a/lib/components/term-group.js +++ b/lib/components/term-group.tsx @@ -1,40 +1,47 @@ import React from 'react'; + import {connect} from 'react-redux'; -import {decorate, getTermProps, getTermGroupProps} from '../utils/plugins'; + +import type {HyperState, HyperDispatch, TermGroupProps, TermGroupOwnProps} from '../../typings/hyper'; import {resizeTermGroup} from '../actions/term-groups'; -import Term_ from './term'; +import {decorate, getTermProps, getTermGroupProps} from '../utils/plugins'; + import SplitPane_ from './split-pane'; +import Term_ from './term'; const Term = decorate(Term_, 'Term'); const SplitPane = decorate(SplitPane_, 'SplitPane'); -class TermGroup_ extends React.PureComponent { - constructor(props, context) { +class TermGroup_ extends React.PureComponent { + bound: WeakMap<(uid: string, ...args: any[]) => any, Record any>>; + term?: Term_ | null; + constructor(props: TermGroupProps, context: any) { super(props, context); this.bound = new WeakMap(); - this.termRefs = {}; - this.sizeChanged = false; - this.onTermRef = this.onTermRef.bind(this); } - bind(fn, thisObj, uid) { + bind any>( + fn: T, + thisObj: any, + uid: string + ): (...args: T extends (uid: string, ..._args: infer I) => any ? I : never) => ReturnType { if (!this.bound.has(fn)) { this.bound.set(fn, {}); } - const map = this.bound.get(fn); + const map = this.bound.get(fn)!; if (!map[uid]) { map[uid] = fn.bind(thisObj, uid); } return map[uid]; } - renderSplit(groups) { + renderSplit(groups: JSX.Element[]) { const [first, ...rest] = groups; if (rest.length === 0) { return first; } - const direction = this.props.termGroup.direction.toLowerCase(); + const direction = this.props.termGroup.direction!.toLowerCase() as 'horizontal' | 'vertical'; return ( { this.term = term; this.props.ref_(uid, term); - } + }; - renderTerm(uid) { + renderTerm(uid: string) { const session = this.props.sessions[uid]; const termRef = this.props.terms[uid]; const props = getTermProps(uid, this.props, { isTermActive: uid === this.props.activeSession, term: termRef ? termRef.term : null, + fitAddon: termRef ? termRef.fitAddon : null, + searchAddon: termRef ? termRef.searchAddon : null, scrollback: this.props.scrollback, backgroundColor: this.props.backgroundColor, foregroundColor: this.props.foregroundColor, @@ -76,22 +85,31 @@ class TermGroup_ extends React.PureComponent { letterSpacing: this.props.letterSpacing, modifierKeys: this.props.modifierKeys, padding: this.props.padding, - url: session.url, cleared: session.cleared, + search: session.search, cols: session.cols, rows: session.rows, copyOnSelect: this.props.copyOnSelect, bell: this.props.bell, bellSoundURL: this.props.bellSoundURL, + bellSound: this.props.bellSound, onActive: this.bind(this.props.onActive, null, uid), onResize: this.bind(this.props.onResize, null, uid), onTitle: this.bind(this.props.onTitle, null, uid), onData: this.bind(this.props.onData, null, uid), - onURLAbort: this.bind(this.props.onURLAbort, null, uid), + onOpenSearch: this.bind(this.props.onOpenSearch, null, uid), + onCloseSearch: this.bind(this.props.onCloseSearch, null, uid), onContextMenu: this.bind(this.props.onContextMenu, null, uid), borderColor: this.props.borderColor, selectionColor: this.props.selectionColor, quickEdit: this.props.quickEdit, + webGLRenderer: this.props.webGLRenderer, + webLinksActivationKey: this.props.webLinksActivationKey, + macOptionSelectionMode: this.props.macOptionSelectionMode, + disableLigatures: this.props.disableLigatures, + screenReaderMode: this.props.screenReaderMode, + windowsPty: this.props.windowsPty, + imageSupport: this.props.imageSupport, uid }); @@ -101,30 +119,17 @@ class TermGroup_ extends React.PureComponent { return ; } - componentWillReceiveProps(nextProps) { - if (this.props.termGroup.sizes != nextProps.termGroup.sizes || nextProps.sizeChanged) { - this.term && this.term.fitResize(); - // Indicate to children that their size has changed even if their ratio hasn't - this.sizeChanged = true; - } else { - this.sizeChanged = false; - } - } - render() { const {childGroups, termGroup} = this.props; if (termGroup.sessionUid) { return this.renderTerm(termGroup.sessionUid); } - const groups = childGroups.map(child => { + const groups = childGroups.asMutable().map((child) => { const props = getTermGroupProps( child.uid, this.props.parentProps, - Object.assign({}, this.props, { - termGroup: child, - sizeChanged: this.sizeChanged - }) + Object.assign({}, this.props, {termGroup: child}) ); return ; @@ -134,17 +139,20 @@ class TermGroup_ extends React.PureComponent { } } -const TermGroup = connect( - (state, ownProps) => ({ - childGroups: ownProps.termGroup.children.map(uid => state.termGroups.termGroups[uid]) - }), - (dispatch, ownProps) => ({ - onTermGroupResize(splitSizes) { - dispatch(resizeTermGroup(ownProps.termGroup.uid, splitSizes)); - } - }) -)(TermGroup_); +const mapStateToProps = (state: HyperState, ownProps: TermGroupOwnProps) => ({ + childGroups: ownProps.termGroup.children.map((uid) => state.termGroups.termGroups[uid]) +}); + +const mapDispatchToProps = (dispatch: HyperDispatch, ownProps: TermGroupOwnProps) => ({ + onTermGroupResize(splitSizes: number[]) { + dispatch(resizeTermGroup(ownProps.termGroup.uid, splitSizes)); + } +}); + +const TermGroup = connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(TermGroup_); const DecoratedTermGroup = decorate(TermGroup, 'TermGroup'); export default TermGroup; + +export type TermGroupConnectedProps = ReturnType & ReturnType; diff --git a/lib/components/term.js b/lib/components/term.js deleted file mode 100644 index 8bbd4c73..00000000 --- a/lib/components/term.js +++ /dev/null @@ -1,337 +0,0 @@ -/* global Blob,URL,requestAnimationFrame */ -import React from 'react'; -import {Terminal} from 'xterm'; -import * as fit from 'xterm/lib/addons/fit/fit'; -import * as webLinks from 'xterm/lib/addons/webLinks/webLinks'; -import * as winptyCompat from 'xterm/lib/addons/winptyCompat/winptyCompat'; -import {clipboard} from 'electron'; -import * as Color from 'color'; -import terms from '../terms'; -import processClipboard from '../utils/paste'; - -Terminal.applyAddon(fit); -Terminal.applyAddon(webLinks); -Terminal.applyAddon(winptyCompat); - -// map old hterm constants to xterm.js -const CURSOR_STYLES = { - BEAM: 'bar', - UNDERLINE: 'underline', - BLOCK: 'block' -}; - -const getTermOptions = props => { - // Set a background color only if it is opaque - const needTransparency = Color(props.backgroundColor).alpha() < 1; - const backgroundColor = needTransparency ? 'transparent' : props.backgroundColor; - return { - macOptionIsMeta: props.modifierKeys.altIsMeta, - scrollback: props.scrollback, - cursorStyle: CURSOR_STYLES[props.cursorShape], - cursorBlink: props.cursorBlink, - fontFamily: props.fontFamily, - fontSize: props.fontSize, - fontWeight: props.fontWeight, - fontWeightBold: props.fontWeightBold, - lineHeight: props.lineHeight, - letterSpacing: props.letterSpacing, - allowTransparency: needTransparency, - experimentalCharAtlas: 'dynamic', - theme: { - foreground: props.foregroundColor, - background: backgroundColor, - cursor: props.cursorColor, - cursorAccent: props.cursorAccentColor, - selection: props.selectionColor, - black: props.colors.black, - red: props.colors.red, - green: props.colors.green, - yellow: props.colors.yellow, - blue: props.colors.blue, - magenta: props.colors.magenta, - cyan: props.colors.cyan, - white: props.colors.white, - brightBlack: props.colors.lightBlack, - brightRed: props.colors.lightRed, - brightGreen: props.colors.lightGreen, - brightYellow: props.colors.lightYellow, - brightBlue: props.colors.lightBlue, - brightMagenta: props.colors.lightMagenta, - brightCyan: props.colors.lightCyan, - brightWhite: props.colors.lightWhite - } - }; -}; - -export default class Term extends React.PureComponent { - constructor(props) { - super(props); - props.ref_(props.uid, this); - this.termRef = null; - this.termWrapperRef = null; - this.termRect = null; - this.onOpen = this.onOpen.bind(this); - this.onWindowResize = this.onWindowResize.bind(this); - this.onWindowPaste = this.onWindowPaste.bind(this); - this.onTermRef = this.onTermRef.bind(this); - this.onTermWrapperRef = this.onTermWrapperRef.bind(this); - this.onMouseUp = this.onMouseUp.bind(this); - this.termOptions = {}; - this.disposableListeners = []; - } - - componentDidMount() { - const {props} = this; - - this.termOptions = getTermOptions(props); - this.term = props.term || new Terminal(this.termOptions); - this.term.attachCustomKeyEventHandler(this.keyboardHandler); - this.term.open(this.termRef); - this.term.webLinksInit(); - this.term.winptyCompatInit(); - - if (props.term) { - //We need to set options again after reattaching an existing term - Object.keys(this.termOptions).forEach(option => this.term.setOption(option, this.termOptions[option])); - } - if (this.props.isTermActive) { - this.term.focus(); - } - - this.onOpen(this.termOptions); - - if (props.onTitle) { - this.disposableListeners.push(this.term.addDisposableListener('title', props.onTitle)); - } - - if (props.onActive) { - this.disposableListeners.push(this.term.addDisposableListener('focus', props.onActive)); - } - - if (props.onData) { - this.disposableListeners.push(this.term.addDisposableListener('data', props.onData)); - } - - if (props.onResize) { - this.disposableListeners.push( - this.term.addDisposableListener('resize', ({cols, rows}) => { - props.onResize(cols, rows); - }) - ); - } - - if (props.onCursorMove) { - this.disposableListeners.push( - this.term.addDisposableListener('cursormove', () => { - const cursorFrame = { - x: this.term._core.buffer.x * this.term._core.renderer.dimensions.actualCellWidth, - y: this.term._core.buffer.y * this.term._core.renderer.dimensions.actualCellHeight, - width: this.term._core.renderer.dimensions.actualCellWidth, - height: this.term._core.renderer.dimensions.actualCellHeight, - col: this.term._core.buffer.y, - row: this.term._core.buffer.x - }; - props.onCursorMove(cursorFrame); - }) - ); - } - - window.addEventListener('resize', this.onWindowResize, { - passive: true - }); - - window.addEventListener('paste', this.onWindowPaste, { - capture: true - }); - - terms[this.props.uid] = this; - } - - onOpen() { - // we need to delay one frame so that styles - // get applied and we can make an accurate measurement - // of the container width and height - requestAnimationFrame(() => { - this.fitResize(); - }); - } - - getTermDocument() { - // eslint-disable-next-line no-console - console.warn( - 'The underlying terminal engine of Hyper no longer ' + - 'uses iframes with individual `document` objects for each ' + - 'terminal instance. This method call is retained for ' + - "backwards compatibility reasons. It's ok to attach directly" + - 'to the `document` object of the main `window`.' - ); - return document; - } - - onWindowResize() { - this.fitResize(); - } - - // intercepting paste event for any necessary processing of - // clipboard data, if result is falsy, paste event continues - onWindowPaste(e) { - if (!this.props.isTermActive) return; - - const processed = processClipboard(); - if (processed) { - e.preventDefault(); - e.stopPropagation(); - this.term.send(processed); - } - } - - onMouseUp(e) { - if (this.props.quickEdit && e.button === 2) { - if (this.term.hasSelection()) { - clipboard.writeText(this.term.getSelection()); - this.term.clearSelection(); - } else { - document.execCommand('paste'); - } - } else if (this.props.copyOnSelect && this.term.hasSelection()) { - clipboard.writeText(this.term.getSelection()); - } - } - - write(data) { - this.term.write(data); - } - - focus() { - this.term.focus(); - } - - clear() { - this.term.clear(); - } - - reset() { - this.term.reset(); - } - - resize(cols, rows) { - this.term.resize(cols, rows); - } - - selectAll() { - this.term.selectAll(); - } - - fitResize() { - if (!this.termWrapperRef) { - return; - } - this.term.fit(); - } - - keyboardHandler(e) { - // Has Mousetrap flagged this event as a command? - return !e.catched; - } - - componentWillReceiveProps(nextProps) { - if (!this.props.cleared && nextProps.cleared) { - this.clear(); - } - const nextTermOptions = getTermOptions(nextProps); - - // Update only options that have changed. - Object.keys(nextTermOptions) - .filter(option => option !== 'theme' && nextTermOptions[option] !== this.termOptions[option]) - .forEach(option => this.term.setOption(option, nextTermOptions[option])); - - // Do we need to update theme? - const shouldUpdateTheme = - !this.termOptions.theme || - Object.keys(nextTermOptions.theme).some( - option => nextTermOptions.theme[option] !== this.termOptions.theme[option] - ); - if (shouldUpdateTheme) { - this.term.setOption('theme', nextTermOptions.theme); - } - - this.termOptions = nextTermOptions; - - if (!this.props.isTermActive && nextProps.isTermActive) { - requestAnimationFrame(() => { - this.fitResize(); - }); - } - - if ( - this.props.fontSize !== nextProps.fontSize || - this.props.fontFamily !== nextProps.fontFamily || - this.props.lineHeight !== nextProps.lineHeight || - this.props.letterSpacing !== nextProps.letterSpacing - ) { - // resize to fit the container - this.fitResize(); - } - - if (nextProps.rows !== this.props.rows || nextProps.cols !== this.props.cols) { - this.resize(nextProps.cols, nextProps.rows); - } - } - - onTermWrapperRef(component) { - this.termWrapperRef = component; - } - - onTermRef(component) { - this.termRef = component; - } - - componentWillUnmount() { - terms[this.props.uid] = null; - this.props.ref_(this.props.uid, null); - - // to clean up the terminal, we remove the listeners - // instead of invoking `destroy`, since it will make the - // term insta un-attachable in the future (which we need - // to do in case of splitting, see `componentDidMount` - this.disposableListeners.forEach(handler => handler.dispose()); - this.disposableListeners = []; - - window.removeEventListener('resize', this.onWindowResize, { - passive: true - }); - - window.removeEventListener('paste', this.onWindowPaste, { - capture: true - }); - } - - render() { - return ( -
    - {this.props.customChildrenBefore} -
    -
    -
    - {this.props.customChildren} - - -
    - ); - } -} diff --git a/lib/components/term.tsx b/lib/components/term.tsx new file mode 100644 index 00000000..45c1464c --- /dev/null +++ b/lib/components/term.tsx @@ -0,0 +1,569 @@ +import {clipboard, shell} from 'electron'; +import React from 'react'; + +import Color from 'color'; +import isEqual from 'lodash/isEqual'; +import pickBy from 'lodash/pickBy'; +import {Terminal} from 'xterm'; +import type {ITerminalOptions, IDisposable} from 'xterm'; +import {CanvasAddon} from 'xterm-addon-canvas'; +import {FitAddon} from 'xterm-addon-fit'; +import {ImageAddon} from 'xterm-addon-image'; +import {LigaturesAddon} from 'xterm-addon-ligatures'; +import {SearchAddon} from 'xterm-addon-search'; +import type {ISearchDecorationOptions} from 'xterm-addon-search'; +import {Unicode11Addon} from 'xterm-addon-unicode11'; +import {WebLinksAddon} from 'xterm-addon-web-links'; +import {WebglAddon} from 'xterm-addon-webgl'; + +import type {TermProps} from '../../typings/hyper'; +import terms from '../terms'; +import processClipboard from '../utils/paste'; +import {decorate} from '../utils/plugins'; + +import _SearchBox from './searchBox'; + +import 'xterm/css/xterm.css'; + +const SearchBox = decorate(_SearchBox, 'SearchBox'); + +const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].includes(navigator.platform) || process.platform === 'win32'; + +// map old hterm constants to xterm.js +const CURSOR_STYLES = { + BEAM: 'bar', + UNDERLINE: 'underline', + BLOCK: 'block' +} as const; + +const isWebgl2Supported = (() => { + let isSupported = window.WebGL2RenderingContext ? undefined : false; + return () => { + if (isSupported === undefined) { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2', {depth: false, antialias: false}); + isSupported = gl instanceof window.WebGL2RenderingContext; + } + return isSupported; + }; +})(); + +const getTermOptions = (props: TermProps): ITerminalOptions => { + // Set a background color only if it is opaque + const needTransparency = Color(props.backgroundColor).alpha() < 1; + const backgroundColor = needTransparency ? 'rgba(0,0,0,0)' : props.backgroundColor; + + return { + macOptionIsMeta: props.modifierKeys.altIsMeta, + scrollback: props.scrollback, + cursorStyle: CURSOR_STYLES[props.cursorShape], + cursorBlink: props.cursorBlink, + fontFamily: props.fontFamily, + fontSize: props.fontSize, + fontWeight: props.fontWeight, + fontWeightBold: props.fontWeightBold, + lineHeight: props.lineHeight, + letterSpacing: props.letterSpacing, + allowTransparency: needTransparency, + macOptionClickForcesSelection: props.macOptionSelectionMode === 'force', + windowsMode: isWindows, + ...(isWindows && props.windowsPty && {windowsPty: props.windowsPty}), + theme: { + foreground: props.foregroundColor, + background: backgroundColor, + cursor: props.cursorColor, + cursorAccent: props.cursorAccentColor, + selectionBackground: props.selectionColor, + black: props.colors.black, + red: props.colors.red, + green: props.colors.green, + yellow: props.colors.yellow, + blue: props.colors.blue, + magenta: props.colors.magenta, + cyan: props.colors.cyan, + white: props.colors.white, + brightBlack: props.colors.lightBlack, + brightRed: props.colors.lightRed, + brightGreen: props.colors.lightGreen, + brightYellow: props.colors.lightYellow, + brightBlue: props.colors.lightBlue, + brightMagenta: props.colors.lightMagenta, + brightCyan: props.colors.lightCyan, + brightWhite: props.colors.lightWhite + }, + screenReaderMode: props.screenReaderMode, + overviewRulerWidth: 20, + allowProposedApi: true + }; +}; + +export default class Term extends React.PureComponent< + TermProps, + { + searchOptions: { + caseSensitive: boolean; + wholeWord: boolean; + regex: boolean; + }; + searchResults: + | { + resultIndex: number; + resultCount: number; + } + | undefined; + } +> { + termRef: HTMLElement | null; + termWrapperRef: HTMLElement | null; + termOptions: ITerminalOptions; + disposableListeners: IDisposable[]; + defaultBellSound: HTMLAudioElement | null; + bellSound: HTMLAudioElement | null; + fitAddon: FitAddon; + searchAddon: SearchAddon; + static rendererTypes: Record; + term!: Terminal; + resizeObserver!: ResizeObserver; + resizeTimeout!: NodeJS.Timeout; + searchDecorations: ISearchDecorationOptions; + state = { + searchOptions: { + caseSensitive: false, + wholeWord: false, + regex: false + }, + searchResults: undefined + }; + + constructor(props: TermProps) { + super(props); + props.ref_(props.uid, this); + this.termRef = null; + this.termWrapperRef = null; + this.termOptions = {}; + this.disposableListeners = []; + this.defaultBellSound = null; + this.bellSound = null; + this.fitAddon = new FitAddon(); + this.searchAddon = new SearchAddon(); + this.searchDecorations = { + activeMatchColorOverviewRuler: Color(this.props.cursorColor).hex(), + matchOverviewRuler: Color(this.props.borderColor).hex(), + activeMatchBackground: Color(this.props.cursorColor).hex(), + activeMatchBorder: Color(this.props.cursorColor).hex(), + matchBorder: Color(this.props.cursorColor).hex() + }; + } + + // The main process shows this in the About dialog + static reportRenderer(uid: string, type: string) { + const rendererTypes = Term.rendererTypes || {}; + if (rendererTypes[uid] !== type) { + rendererTypes[uid] = type; + Term.rendererTypes = rendererTypes; + window.rpc.emit('info renderer', {uid, type}); + } + } + + componentDidMount() { + const {props} = this; + + this.termOptions = getTermOptions(props); + this.term = props.term || new Terminal(this.termOptions); + this.defaultBellSound = new Audio( + // Source: https://freesound.org/people/altemark/sounds/45759/ + // This sound is released under the Creative Commons Attribution 3.0 Unported + // (CC BY 3.0) license. It was created by 'altemark'. No modifications have been + // made, apart from the conversion to base64. + 'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4LjMyLjEwNAAAAAAAAAAAAAAA//tQxAADB8AhSmxhIIEVCSiJrDCQBTcu3UrAIwUdkRgQbFAZC1CQEwTJ9mjRvBA4UOLD8nKVOWfh+UlK3z/177OXrfOdKl7pyn3Xf//WreyTRUoAWgBgkOAGbZHBgG1OF6zM82DWbZaUmMBptgQhGjsyYqc9ae9XFz280948NMBWInljyzsNRFLPWdnZGWrddDsjK1unuSrVN9jJsK8KuQtQCtMBjCEtImISdNKJOopIpBFpNSMbIHCSRpRR5iakjTiyzLhchUUBwCgyKiweBv/7UsQbg8isVNoMPMjAAAA0gAAABEVFGmgqK////9bP/6XCykxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq' + ); + this.setBellSound(props.bell, props.bellSound); + + // The parent element for the terminal is attached and removed manually so + // that we can preserve it across mounts and unmounts of the component + this.termRef = props.term ? props.term.element!.parentElement! : document.createElement('div'); + this.termRef.className = 'term_fit term_term'; + + this.termWrapperRef?.appendChild(this.termRef); + + if (!props.term) { + const needTransparency = Color(props.backgroundColor).alpha() < 1; + let useWebGL = false; + if (props.webGLRenderer) { + if (needTransparency) { + console.warn( + 'WebGL Renderer has been disabled since it does not support transparent backgrounds yet. ' + + 'Falling back to canvas-based rendering.' + ); + } else if (!isWebgl2Supported()) { + console.warn('WebGL2 is not supported on your machine. Falling back to canvas-based rendering.'); + } else { + // Experimental WebGL renderer needs some more glue-code to make it work on Hyper. + // If you're working on enabling back WebGL, you will also need to look into `xterm-addon-ligatures` support for that renderer. + useWebGL = true; + } + } + Term.reportRenderer(props.uid, useWebGL ? 'WebGL' : 'Canvas'); + + const shallActivateWebLink = (event: MouseEvent): boolean => { + if (!event) return false; + return props.webLinksActivationKey ? event[`${props.webLinksActivationKey}Key`] : true; + }; + + // eslint-disable-next-line @typescript-eslint/unbound-method + this.term.attachCustomKeyEventHandler(this.keyboardHandler); + this.term.loadAddon(this.fitAddon); + this.term.loadAddon(this.searchAddon); + this.term.loadAddon( + new WebLinksAddon((event, uri) => { + if (shallActivateWebLink(event)) void shell.openExternal(uri); + }) + ); + this.term.open(this.termRef); + + if (useWebGL) { + const webglAddon = new WebglAddon(); + this.term.loadAddon(webglAddon); + webglAddon.onContextLoss(() => { + console.warn('WebGL context lost. Falling back to canvas-based rendering.'); + webglAddon.dispose(); + this.term.loadAddon(new CanvasAddon()); + }); + } else { + this.term.loadAddon(new CanvasAddon()); + } + + if (props.disableLigatures !== true && !useWebGL) { + this.term.loadAddon(new LigaturesAddon()); + } + + this.term.loadAddon(new Unicode11Addon()); + this.term.unicode.activeVersion = '11'; + + if (props.imageSupport) { + this.term.loadAddon(new ImageAddon()); + } + } else { + // get the cached plugins + this.fitAddon = props.fitAddon!; + this.searchAddon = props.searchAddon!; + } + + try { + this.term.element!.style.padding = props.padding; + } catch (error) { + console.log(error); + } + + this.fitAddon.fit(); + + if (this.props.isTermActive) { + this.term.focus(); + } + + if (props.onTitle) { + this.disposableListeners.push(this.term.onTitleChange(props.onTitle)); + } + + if (props.onActive) { + this.term.textarea?.addEventListener('focus', props.onActive); + this.disposableListeners.push({ + dispose: () => this.term.textarea?.removeEventListener('focus', this.props.onActive) + }); + } + + if (props.onData) { + this.disposableListeners.push(this.term.onData(props.onData)); + } + + this.term.onBell(() => { + this.ringBell(); + }); + + if (props.onResize) { + this.disposableListeners.push( + this.term.onResize(({cols, rows}) => { + props.onResize(cols, rows); + }) + ); + + // the row and col of init session is null, so reize the node-pty + props.onResize(this.term.cols, this.term.rows); + } + + if (props.onCursorMove) { + this.disposableListeners.push( + this.term.onCursorMove(() => { + const cursorFrame = { + x: this.term.buffer.active.cursorX * (this.term as any)._core._renderService.dimensions.actualCellWidth, + y: this.term.buffer.active.cursorY * (this.term as any)._core._renderService.dimensions.actualCellHeight, + width: (this.term as any)._core._renderService.dimensions.actualCellWidth, + height: (this.term as any)._core._renderService.dimensions.actualCellHeight, + col: this.term.buffer.active.cursorX, + row: this.term.buffer.active.cursorY + }; + props.onCursorMove?.(cursorFrame); + }) + ); + } + + this.disposableListeners.push( + this.searchAddon.onDidChangeResults((results) => { + this.setState((state) => ({ + ...state, + searchResults: results + })); + }) + ); + + window.addEventListener('paste', this.onWindowPaste, { + capture: true + }); + + terms[this.props.uid] = this; + } + + getTermDocument() { + console.warn( + 'The underlying terminal engine of Hyper no longer ' + + 'uses iframes with individual `document` objects for each ' + + 'terminal instance. This method call is retained for ' + + "backwards compatibility reasons. It's ok to attach directly" + + 'to the `document` object of the main `window`.' + ); + return document; + } + + // intercepting paste event for any necessary processing of + // clipboard data, if result is falsy, paste event continues + onWindowPaste = (e: Event) => { + if (!this.props.isTermActive) return; + + const processed = processClipboard(); + if (processed) { + e.preventDefault(); + e.stopPropagation(); + this.term.paste(processed); + } + }; + + onMouseUp = (e: React.MouseEvent) => { + if (this.props.quickEdit && e.button === 2) { + if (this.term.hasSelection()) { + clipboard.writeText(this.term.getSelection()); + this.term.clearSelection(); + } else { + document.execCommand('paste'); + } + } else if (this.props.copyOnSelect && this.term.hasSelection()) { + clipboard.writeText(this.term.getSelection()); + } + }; + + write(data: string | Uint8Array) { + this.term.write(data); + } + + focus = () => { + this.term.focus(); + }; + + clear() { + this.term.clear(); + } + + reset() { + this.term.reset(); + } + + searchNext = (searchTerm: string) => { + this.searchAddon.findNext(searchTerm, { + ...this.state.searchOptions, + decorations: this.searchDecorations + }); + }; + + searchPrevious = (searchTerm: string) => { + this.searchAddon.findPrevious(searchTerm, { + ...this.state.searchOptions, + decorations: this.searchDecorations + }); + }; + + closeSearchBox = () => { + this.props.onCloseSearch(); + this.searchAddon.clearDecorations(); + this.searchAddon.clearActiveDecoration(); + this.setState((state) => ({ + ...state, + searchResults: undefined + })); + this.term.focus(); + }; + + resize(cols: number, rows: number) { + this.term.resize(cols, rows); + } + + selectAll() { + this.term.selectAll(); + } + + fitResize() { + if (!this.termWrapperRef) { + return; + } + this.fitAddon.fit(); + } + + keyboardHandler(e: any) { + // Has Mousetrap flagged this event as a command? + return !e.catched; + } + + setBellSound(bell: 'SOUND' | false, sound: string | null) { + if (bell && bell.toUpperCase() === 'SOUND') { + this.bellSound = sound ? new Audio(sound) : this.defaultBellSound; + } else { + this.bellSound = null; + } + } + + ringBell() { + void this.bellSound?.play(); + } + + componentDidUpdate(prevProps: TermProps) { + if (!prevProps.cleared && this.props.cleared) { + this.clear(); + } + + const nextTermOptions = getTermOptions(this.props); + + if (prevProps.bell !== this.props.bell || prevProps.bellSound !== this.props.bellSound) { + this.setBellSound(this.props.bell, this.props.bellSound); + } + + if (prevProps.search && !this.props.search) { + this.closeSearchBox(); + } + + // Update only options that have changed. + this.term.options = pickBy( + nextTermOptions, + (value, key) => !isEqual(this.termOptions[key as keyof ITerminalOptions], value) + ); + + this.termOptions = nextTermOptions; + + try { + this.term.element!.style.padding = this.props.padding; + } catch (error) { + console.log(error); + } + + if ( + this.props.fontSize !== prevProps.fontSize || + this.props.fontFamily !== prevProps.fontFamily || + this.props.lineHeight !== prevProps.lineHeight || + this.props.letterSpacing !== prevProps.letterSpacing + ) { + // resize to fit the container + this.fitResize(); + } + + if (prevProps.rows !== this.props.rows || prevProps.cols !== this.props.cols) { + this.resize(this.props.cols!, this.props.rows!); + } + } + + onTermWrapperRef = (component: HTMLElement | null) => { + this.termWrapperRef = component; + + if (component) { + this.resizeObserver = new ResizeObserver(() => { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = setTimeout(() => { + this.fitResize(); + }, 500); + }); + this.resizeObserver.observe(component); + } else { + this.resizeObserver.disconnect(); + } + }; + + componentWillUnmount() { + terms[this.props.uid] = null; + this.termWrapperRef?.removeChild(this.termRef!); + this.props.ref_(this.props.uid, null); + + // to clean up the terminal, we remove the listeners + // instead of invoking `destroy`, since it will make the + // term insta un-attachable in the future (which we need + // to do in case of splitting, see `componentDidMount` + this.disposableListeners.forEach((handler) => handler.dispose()); + this.disposableListeners = []; + + window.removeEventListener('paste', this.onWindowPaste, { + capture: true + }); + } + + render() { + return ( +
    + {this.props.customChildrenBefore} +
    + {this.props.customChildren} + {this.props.search ? ( + + this.setState({ + ...this.state, + searchOptions: {...this.state.searchOptions, caseSensitive: !this.state.searchOptions.caseSensitive} + }) + } + toggleWholeWord={() => + this.setState({ + ...this.state, + searchOptions: {...this.state.searchOptions, wholeWord: !this.state.searchOptions.wholeWord} + }) + } + toggleRegex={() => + this.setState({ + ...this.state, + searchOptions: {...this.state.searchOptions, regex: !this.state.searchOptions.regex} + }) + } + selectionColor={this.props.selectionColor} + backgroundColor={this.props.backgroundColor} + foregroundColor={this.props.foregroundColor} + borderColor={this.props.borderColor} + font={this.props.uiFontFamily} + /> + ) : null} + + +
    + ); + } +} diff --git a/lib/components/terms.js b/lib/components/terms.tsx similarity index 61% rename from lib/components/terms.js rename to lib/components/terms.tsx index 0d8a36b1..3774b02d 100644 --- a/lib/components/terms.js +++ b/lib/components/terms.tsx @@ -1,76 +1,73 @@ import React from 'react'; -import {decorate, getTermGroupProps} from '../utils/plugins'; + +import type {TermsProps, HyperDispatch} from '../../typings/hyper'; import {registerCommandHandlers} from '../command-registry'; -import TermGroup_ from './term-group'; +import {ObjectTypedKeys} from '../utils/object'; +import {decorate, getTermGroupProps} from '../utils/plugins'; + import StyleSheet_ from './style-sheet'; +import type Term from './term'; +import TermGroup_ from './term-group'; const TermGroup = decorate(TermGroup_, 'TermGroup'); const StyleSheet = decorate(StyleSheet_, 'StyleSheet'); const isMac = /Mac/.test(navigator.userAgent); -export default class Terms extends React.Component { - constructor(props, context) { +export default class Terms extends React.Component> { + terms: Record; + registerCommands: (cmds: Record void>) => void; + constructor(props: TermsProps, context: any) { super(props, context); this.terms = {}; - this.bound = new WeakMap(); - this.onRef = this.onRef.bind(this); this.registerCommands = registerCommandHandlers; props.ref_(this); } - shouldComponentUpdate(nextProps) { - for (const i in nextProps) { - if (i === 'write') { - continue; - } - if (this.props[i] !== nextProps[i]) { - return true; - } - } - for (const i in this.props) { - if (i === 'write') { - continue; - } - if (this.props[i] !== nextProps[i]) { - return true; - } - } - return false; + shouldComponentUpdate(nextProps: TermsProps & {children: any}) { + return ( + ObjectTypedKeys(nextProps).some((i) => i !== 'write' && this.props[i] !== nextProps[i]) || + ObjectTypedKeys(this.props).some((i) => i !== 'write' && this.props[i] !== nextProps[i]) + ); } - onRef(uid, term) { + onRef = (uid: string, term: Term | null) => { if (term) { this.terms[uid] = term; - } else if (!this.props.sessions[uid]) { - delete this.terms[uid]; } - } + }; - getTermByUid(uid) { + getTermByUid(uid: string) { return this.terms[uid]; } getActiveTerm() { - return this.getTermByUid(this.props.activeSession); + return this.getTermByUid(this.props.activeSession!); } - getLastTermIndex() { - return this.props.sessions.length - 1; - } - - onTerminal(uid, term) { + onTerminal(uid: string, term: Term) { this.terms[uid] = term; } componentDidMount() { window.addEventListener('contextmenu', () => { - const selection = window.getSelection().toString(); - const {props: {uid}} = this.getActiveTerm(); + const selection = window.getSelection()!.toString(); + const { + props: {uid} + } = this.getActiveTerm(); this.props.onContextMenu(uid, selection); }); } + componentDidUpdate(prevProps: TermsProps) { + for (const uid in prevProps.sessions) { + if (!this.props.sessions[uid]) { + this.terms[uid].term.dispose(); + delete this.terms[uid]; + } + } + } + componentWillUnmount() { this.props.ref_(null); } @@ -78,9 +75,9 @@ export default class Terms extends React.Component { render() { const shift = !isMac && this.props.termGroups.length > 1; return ( -
    +
    {this.props.customChildrenBefore} - {this.props.termGroups.map(termGroup => { + {this.props.termGroups.map((termGroup) => { const {uid} = termGroup; const isActive = uid === this.props.activeRootGroup; const props = getTermGroupProps(uid, this.props, { @@ -97,6 +94,7 @@ export default class Terms extends React.Component { cursorShape: this.props.cursorShape, cursorBlink: this.props.cursorBlink, cursorColor: this.props.cursorColor, + cursorAccentColor: this.props.cursorAccentColor, fontSize: this.props.fontSize, fontFamily: this.props.fontFamily, uiFontFamily: this.props.uiFontFamily, @@ -107,15 +105,24 @@ export default class Terms extends React.Component { padding: this.props.padding, bell: this.props.bell, bellSoundURL: this.props.bellSoundURL, + bellSound: this.props.bellSound, copyOnSelect: this.props.copyOnSelect, modifierKeys: this.props.modifierKeys, onActive: this.props.onActive, onResize: this.props.onResize, onTitle: this.props.onTitle, onData: this.props.onData, - onURLAbort: this.props.onURLAbort, + onOpenSearch: this.props.onOpenSearch, + onCloseSearch: this.props.onCloseSearch, onContextMenu: this.props.onContextMenu, quickEdit: this.props.quickEdit, + webGLRenderer: this.props.webGLRenderer, + webLinksActivationKey: this.props.webLinksActivationKey, + macOptionSelectionMode: this.props.macOptionSelectionMode, + disableLigatures: this.props.disableLigatures, + screenReaderMode: this.props.screenReaderMode, + windowsPty: this.props.windowsPty, + imageSupport: this.props.imageSupport, parentProps: this.props }); @@ -143,11 +150,34 @@ export default class Terms extends React.Component { left: 0; bottom: 0; color: #fff; - transition: ${isMac ? 'none' : 'margin-top 0.3s ease'}; } .terms_termsShifted { margin-top: 68px; + animation: shift-down 0.2s ease-out; + } + + .terms_termsNotShifted { + margin-top: 34px; + animation: shift-up 0.3s ease; + } + + @keyframes shift-down { + 0% { + transform: translateY(-34px); + } + 100% { + transform: translateY(0px); + } + } + + @keyframes shift-up { + 0% { + transform: translateY(34px); + } + 100% { + transform: translateY(0px); + } } .terms_termGroup { diff --git a/lib/constants/config.js b/lib/constants/config.js deleted file mode 100644 index 5221aa4e..00000000 --- a/lib/constants/config.js +++ /dev/null @@ -1,2 +0,0 @@ -export const CONFIG_LOAD = 'CONFIG_LOAD'; -export const CONFIG_RELOAD = 'CONFIG_RELOAD'; diff --git a/lib/constants/index.js b/lib/constants/index.js deleted file mode 100644 index ab791e74..00000000 --- a/lib/constants/index.js +++ /dev/null @@ -1,3 +0,0 @@ -const INIT = 'INIT'; - -export default INIT; diff --git a/lib/constants/notifications.js b/lib/constants/notifications.js deleted file mode 100644 index b1387d1c..00000000 --- a/lib/constants/notifications.js +++ /dev/null @@ -1,2 +0,0 @@ -export const NOTIFICATION_MESSAGE = 'NOTIFICATION_MESSAGE'; -export const NOTIFICATION_DISMISS = 'NOTIFICATION_DISMISS'; diff --git a/lib/constants/sessions.js b/lib/constants/sessions.js deleted file mode 100644 index aed788a1..00000000 --- a/lib/constants/sessions.js +++ /dev/null @@ -1,14 +0,0 @@ -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_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_CWD = 'SESSION_SET_CWD'; diff --git a/lib/constants/tabs.js b/lib/constants/tabs.js deleted file mode 100644 index c31375ba..00000000 --- a/lib/constants/tabs.js +++ /dev/null @@ -1,2 +0,0 @@ -export const CLOSE_TAB = 'CLOSE_TAB'; -export const CHANGE_TAB = 'CHANGE_TAB'; diff --git a/lib/constants/term-groups.js b/lib/constants/term-groups.js deleted file mode 100644 index 8a084ee8..00000000 --- a/lib/constants/term-groups.js +++ /dev/null @@ -1,8 +0,0 @@ -export const TERM_GROUP_REQUEST = 'TERM_GROUP_REQUEST'; -export const TERM_GROUP_EXIT = 'TERM_GROUP_EXIT'; -export const TERM_GROUP_RESIZE = 'TERM_GROUP_RESIZE'; -export const TERM_GROUP_EXIT_ACTIVE = 'TERM_GROUP_EXIT_ACTIVE'; -export const DIRECTION = { - HORIZONTAL: 'HORIZONTAL', - VERTICAL: 'VERTICAL' -}; diff --git a/lib/constants/ui.js b/lib/constants/ui.js deleted file mode 100644 index b2d4d29a..00000000 --- a/lib/constants/ui.js +++ /dev/null @@ -1,22 +0,0 @@ -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_FONT_SMOOTHING_SET = 'UI_FONT_SMOOTHING_SET'; -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_MOVE_NEXT_PANE = 'UI_MOVE_NEXT_PANE'; -export const UI_MOVE_PREV_PANE = 'UI_MOVE_PREV_PANE'; -export const UI_SHOW_PREFERENCES = 'UI_SHOW_PREFERENCES'; -export const UI_WINDOW_MOVE = 'UI_WINDOW_MOVE'; -export const UI_WINDOW_MAXIMIZE = 'UI_WINDOW_MAXIMIZE'; -export const UI_WINDOW_UNMAXIMIZE = 'UI_WINDOW_UNMAXIMIZE'; -export const UI_WINDOW_GEOMETRY_CHANGED = 'UI_WINDOW_GEOMETRY_CHANGED'; -export const UI_OPEN_FILE = 'UI_OPEN_FILE'; -export const UI_OPEN_SSH_URL = 'UI_OPEN_SSH_URL'; -export const UI_OPEN_HAMBURGER_MENU = 'UI_OPEN_HAMBURGER_MENU'; -export const UI_WINDOW_MINIMIZE = 'UI_WINDOW_MINIMIZE'; -export const UI_WINDOW_CLOSE = 'UI_WINDOW_CLOSE'; -export const UI_CONTEXTMENU_OPEN = 'UI_CONTEXTMENU_OPEN'; -export const UI_COMMAND_EXEC = 'UI_COMMAND_EXEC'; diff --git a/lib/constants/updater.js b/lib/constants/updater.js deleted file mode 100644 index 624115a9..00000000 --- a/lib/constants/updater.js +++ /dev/null @@ -1,2 +0,0 @@ -export const UPDATE_INSTALL = 'UPDATE_INSTALL'; -export const UPDATE_AVAILABLE = 'UPDATE_AVAILABLE'; diff --git a/lib/containers/header.js b/lib/containers/header.js deleted file mode 100644 index bf7504fc..00000000 --- a/lib/containers/header.js +++ /dev/null @@ -1,77 +0,0 @@ -/* eslint-disable max-params */ -import {createSelector} from 'reselect'; - -import Header from '../components/header'; -import {closeTab, changeTab, maximize, openHamburgerMenu, unmaximize, minimize, close} from '../actions/header'; -import {connect} from '../utils/plugins'; -import getRootGroups from '../selectors'; - -const isMac = /Mac/.test(navigator.userAgent); - -const getSessions = ({sessions}) => sessions.sessions; -const getActiveRootGroup = ({termGroups}) => termGroups.activeRootGroup; -const getActiveSessions = ({termGroups}) => termGroups.activeSessions; -const getActivityMarkers = ({ui}) => ui.activityMarkers; -const getTabs = createSelector( - [getSessions, getRootGroups, getActiveSessions, getActiveRootGroup, getActivityMarkers], - (sessions, rootGroups, activeSessions, activeRootGroup, activityMarkers) => - rootGroups.map(t => { - const activeSessionUid = activeSessions[t.uid]; - const session = sessions[activeSessionUid]; - return { - uid: t.uid, - title: session.title, - isActive: t.uid === activeRootGroup, - hasActivity: activityMarkers[session.uid] - }; - }) -); - -const HeaderContainer = connect( - state => { - return { - // active is an index - isMac, - tabs: getTabs(state), - activeMarkers: state.ui.activityMarkers, - borderColor: state.ui.borderColor, - backgroundColor: state.ui.backgroundColor, - maximized: state.ui.maximized, - showHamburgerMenu: state.ui.showHamburgerMenu, - showWindowControls: state.ui.showWindowControls - }; - }, - dispatch => { - return { - onCloseTab: i => { - dispatch(closeTab(i)); - }, - - onChangeTab: i => { - dispatch(changeTab(i)); - }, - - maximize: () => { - dispatch(maximize()); - }, - - unmaximize: () => { - dispatch(unmaximize()); - }, - - openHamburgerMenu: coordinates => { - dispatch(openHamburgerMenu(coordinates)); - }, - - minimize: () => { - dispatch(minimize()); - }, - - close: () => { - dispatch(close()); - } - }; - } -)(Header, 'Header'); - -export default HeaderContainer; diff --git a/lib/containers/header.ts b/lib/containers/header.ts new file mode 100644 index 00000000..c8e4c9bd --- /dev/null +++ b/lib/containers/header.ts @@ -0,0 +1,86 @@ +import {createSelector} from 'reselect'; + +import type {HyperState, HyperDispatch, ITab} from '../../typings/hyper'; +import {closeTab, changeTab, maximize, openHamburgerMenu, unmaximize, minimize, close} from '../actions/header'; +import {requestTermGroup} from '../actions/term-groups'; +import Header from '../components/header'; +import {getRootGroups} from '../selectors'; +import {connect} from '../utils/plugins'; + +const isMac = /Mac/.test(navigator.userAgent); + +const getSessions = ({sessions}: HyperState) => sessions.sessions; +const getActiveRootGroup = ({termGroups}: HyperState) => termGroups.activeRootGroup; +const getActiveSessions = ({termGroups}: HyperState) => termGroups.activeSessions; +const getActivityMarkers = ({ui}: HyperState) => ui.activityMarkers; +const getTabs = createSelector( + [getSessions, getRootGroups, getActiveSessions, getActiveRootGroup, getActivityMarkers], + (sessions, rootGroups, activeSessions, activeRootGroup, activityMarkers) => + rootGroups.map((t): ITab => { + const activeSessionUid = activeSessions[t.uid]; + const session = sessions[activeSessionUid]; + return { + uid: t.uid, + title: session.title, + isActive: t.uid === activeRootGroup, + hasActivity: activityMarkers[session.uid] + }; + }) +); + +const mapStateToProps = (state: HyperState) => { + return { + // active is an index + isMac, + tabs: getTabs(state), + activeMarkers: state.ui.activityMarkers, + borderColor: state.ui.borderColor, + backgroundColor: state.ui.backgroundColor, + maximized: state.ui.maximized, + fullScreen: state.ui.fullScreen, + showHamburgerMenu: state.ui.showHamburgerMenu, + showWindowControls: state.ui.showWindowControls, + defaultProfile: state.ui.defaultProfile, + profiles: state.ui.profiles + }; +}; + +const mapDispatchToProps = (dispatch: HyperDispatch) => { + return { + onCloseTab: (i: string) => { + dispatch(closeTab(i)); + }, + + onChangeTab: (i: string) => { + dispatch(changeTab(i)); + }, + + maximize: () => { + dispatch(maximize()); + }, + + unmaximize: () => { + dispatch(unmaximize()); + }, + + openHamburgerMenu: (coordinates: {x: number; y: number}) => { + dispatch(openHamburgerMenu(coordinates)); + }, + + minimize: () => { + dispatch(minimize()); + }, + + close: () => { + dispatch(close()); + }, + + openNewTab: (profile: string) => { + dispatch(requestTermGroup(undefined, profile)); + } + }; +}; + +export const HeaderContainer = connect(mapStateToProps, mapDispatchToProps, null)(Header, 'Header'); + +export type HeaderConnectedProps = ReturnType & ReturnType; diff --git a/lib/containers/hyper.js b/lib/containers/hyper.js deleted file mode 100644 index 72b69816..00000000 --- a/lib/containers/hyper.js +++ /dev/null @@ -1,173 +0,0 @@ -/* eslint-disable react/no-danger */ - -import React from 'react'; -import Mousetrap from 'mousetrap'; - -import {connect} from '../utils/plugins'; -import * as uiActions from '../actions/ui'; -import {getRegisteredKeys, getCommandHandler, shouldPreventDefault} from '../command-registry'; -import stylis from 'stylis'; - -import HeaderContainer from './header'; -import TermsContainer from './terms'; -import NotificationsContainer from './notifications'; - -const isMac = /Mac/.test(navigator.userAgent); - -class Hyper extends React.PureComponent { - constructor(props) { - super(props); - this.handleFocusActive = this.handleFocusActive.bind(this); - this.handleSelectAll = this.handleSelectAll.bind(this); - this.onTermsRef = this.onTermsRef.bind(this); - this.mousetrap = null; - this.state = { - lastConfigUpdate: 0 - }; - } - - componentWillReceiveProps(next) { - if (this.props.backgroundColor !== next.backgroundColor) { - // this can be removed when `setBackgroundColor` in electron - // starts working again - document.body.style.backgroundColor = next.backgroundColor; - } - const {lastConfigUpdate} = next; - if (lastConfigUpdate && lastConfigUpdate !== this.state.lastConfigUpdate) { - this.setState({lastConfigUpdate}); - this.attachKeyListeners(); - } - } - - handleFocusActive() { - const term = this.terms.getActiveTerm(); - if (term) { - term.focus(); - } - } - - handleSelectAll() { - const term = this.terms.getActiveTerm(); - if (term) { - term.selectAll(); - } - } - - attachKeyListeners() { - if (!this.mousetrap) { - this.mousetrap = new Mousetrap(window, true); - this.mousetrap.stopCallback = () => { - // All events should be intercepted even if focus is in an input/textarea - return false; - }; - } else { - this.mousetrap.reset(); - } - - const keys = getRegisteredKeys(); - Object.keys(keys).forEach(commandKeys => { - this.mousetrap.bind( - commandKeys, - e => { - const command = keys[commandKeys]; - // We should tell to xterm that it should ignore this event. - e.catched = true; - this.props.execCommand(command, getCommandHandler(command), e); - shouldPreventDefault(command) && e.preventDefault(); - }, - 'keydown' - ); - }); - } - - componentDidMount() { - this.attachKeyListeners(); - window.rpc.on('term selectAll', this.handleSelectAll); - } - - onTermsRef(terms) { - this.terms = terms; - } - - componentDidUpdate(prev) { - if (prev.activeSession !== this.props.activeSession) { - this.handleFocusActive(); - } - } - - componentWillUnmount() { - document.body.style.backgroundColor = 'inherit'; - this.mousetrap && this.mousetrap.reset(); - } - - render() { - const {isMac: isMac_, customCSS, uiFontFamily, borderColor, maximized} = this.props; - const borderWidth = isMac_ ? '' : `${maximized ? '0' : '1'}px`; - - return ( -
    -
    - - - {this.props.customInnerChildren} -
    - - - - {this.props.customChildren} - - - - {/* - Add custom CSS to Hyper. - We add a scope to the customCSS so that it can get around the weighting applied by styled-jsx - */} - + + {/* + Add custom CSS to Hyper. + We add a scope to the customCSS so that it can get around the weighting applied by styled-jsx + */} +