Repository: vv-vim/vv Branch: main Commit: f54a3463cd2b Files: 140 Total size: 268.5 KB Directory structure: gitextract_afbqh6f5/ ├── .eslintrc.js ├── .github/ │ └── workflows/ │ ├── link_typecheck.yml │ └── tests.yml ├── .gitignore ├── .husky/ │ ├── .gitignore │ └── pre-commit ├── .nvmrc ├── .prettierrc.js ├── LICENSE ├── README.md ├── babel.config.json ├── codecov.yml ├── jest.config.js ├── package.json ├── packages/ │ ├── browser-renderer/ │ │ ├── README.md │ │ ├── babel.config.json │ │ ├── config/ │ │ │ ├── jest/ │ │ │ │ ├── afterEnv.js │ │ │ │ ├── globalSetup.js │ │ │ │ ├── globalTeardown.js │ │ │ │ └── testServer.js │ │ │ ├── webpack.config.js │ │ │ └── webpack.prod.config.js │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __tests__/ │ │ │ │ ├── renderer.test.ts │ │ │ │ └── screen.test.ts │ │ │ ├── features/ │ │ │ │ └── hideMouseCursor.ts │ │ │ ├── index.ts │ │ │ ├── input/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── keyboard.test.ts │ │ │ │ ├── keyboard.ts │ │ │ │ └── mouse.ts │ │ │ ├── lib/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── getColor.test.ts │ │ │ │ ├── getColor.ts │ │ │ │ └── isWeb.ts │ │ │ ├── preloaded/ │ │ │ │ └── electron.ts │ │ │ ├── renderer.ts │ │ │ ├── screen.ts │ │ │ ├── transport/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── ipc.test.ts │ │ │ │ │ └── websocket.test.ts │ │ │ │ ├── ipc.ts │ │ │ │ ├── transport.ts │ │ │ │ └── websocket.ts │ │ │ └── types.ts │ │ ├── tsconfig.declaration.json │ │ └── tsconfig.json │ ├── electron/ │ │ ├── @types/ │ │ │ └── html2plaintext.d.ts │ │ ├── README.md │ │ ├── assets/ │ │ │ ├── generic.icns │ │ │ └── icon.icns │ │ ├── babel.config.json │ │ ├── bin/ │ │ │ ├── openInProject.vim │ │ │ ├── reloadChanged.vim │ │ │ ├── vv │ │ │ ├── vv.vim │ │ │ └── vvset.vim │ │ ├── config/ │ │ │ ├── electron-builder/ │ │ │ │ ├── build.js │ │ │ │ ├── fileAssociations.json │ │ │ │ └── release.js │ │ │ ├── webpack.common.config.js │ │ │ ├── webpack.config.js │ │ │ ├── webpack.main.config.js │ │ │ ├── webpack.prod.config.js │ │ │ └── webpack.renderer.config.js │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── scripts/ │ │ │ └── filetypes.js │ │ ├── src/ │ │ │ ├── lib/ │ │ │ │ ├── isDev.ts │ │ │ │ └── log.ts │ │ │ ├── main/ │ │ │ │ ├── autoUpdate.ts │ │ │ │ ├── checkNeovim.ts │ │ │ │ ├── index.ts │ │ │ │ ├── installCli.ts │ │ │ │ ├── lib/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── args.test.ts │ │ │ │ │ ├── args.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── which.ts │ │ │ │ ├── menu.ts │ │ │ │ ├── nvim/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── nvim.test.ts │ │ │ │ │ ├── features/ │ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ │ ├── backrdoundColor.test.ts │ │ │ │ │ │ │ └── windowSize.test.ts │ │ │ │ │ │ ├── backrdoundColor.ts │ │ │ │ │ │ ├── closeWindow.ts │ │ │ │ │ │ ├── copyPaste.ts │ │ │ │ │ │ ├── focusAutocmd.ts │ │ │ │ │ │ ├── quit.ts │ │ │ │ │ │ ├── reloadChanged.ts │ │ │ │ │ │ ├── windowSize.ts │ │ │ │ │ │ ├── windowTitle.ts │ │ │ │ │ │ └── zoom.ts │ │ │ │ │ ├── nvim.ts │ │ │ │ │ ├── nvimByWindow.ts │ │ │ │ │ └── settings.ts │ │ │ │ ├── preload.js │ │ │ │ └── transport/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── ipc.test.ts │ │ │ │ └── ipc.ts │ │ │ └── renderer/ │ │ │ ├── index.html │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── nvim/ │ │ ├── README.md │ │ ├── babel.config.json │ │ ├── config/ │ │ │ ├── webpack.config.js │ │ │ └── webpack.prod.config.js │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── Nvim.ts │ │ │ ├── ProcNvimTransport.ts │ │ │ ├── __generated__/ │ │ │ │ ├── constants.ts │ │ │ │ └── types.ts │ │ │ ├── __tests__/ │ │ │ │ ├── Nvim.test.ts │ │ │ │ ├── ProcNvimTransport.test.ts │ │ │ │ ├── process.test.ts │ │ │ │ └── utils.test.ts │ │ │ ├── browser.ts │ │ │ ├── index.ts │ │ │ ├── process.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.declaration.json │ │ └── tsconfig.json │ └── server/ │ ├── README.md │ ├── babel.config.json │ ├── bin/ │ │ ├── vv.vim │ │ └── vvset.vim │ ├── config/ │ │ ├── webpack.common.config.js │ │ ├── webpack.config.js │ │ ├── webpack.prod.config.js │ │ ├── webpack.renderer.config.js │ │ └── webpack.server.config.js │ ├── jest.config.js │ ├── package.json │ ├── src/ │ │ ├── lib/ │ │ │ └── isDev.ts │ │ ├── renderer/ │ │ │ ├── index.html │ │ │ └── index.ts │ │ └── server/ │ │ ├── index.ts │ │ ├── nvim/ │ │ │ ├── nvim.ts │ │ │ └── settings.ts │ │ └── transport/ │ │ └── websocket.ts │ └── tsconfig.json ├── scripts/ │ └── codegen.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.js ================================================ module.exports = { parser: '@typescript-eslint/parser', plugins: ['jest'], extends: [ 'airbnb-base', // Temporary disable it until upgrade to the Prettier 3 // 'plugin:prettier/recommended', 'plugin:jest/recommended', 'plugin:jest/style', 'plugin:import/typescript', ], env: { browser: true, 'jest/globals': true, }, ignorePatterns: [ '**/tmp/**', '**/build/**', '**/dist/**', '**/node_modules/**', '**/@types/**', '**/__generated__/**', ], settings: { 'import/resolver': { node: { extensions: ['.js', '.ts'], }, }, }, overrides: [ { files: ['*.ts'], plugins: ['jest', '@typescript-eslint'], extends: [ 'airbnb-base', // 'plugin:prettier/recommended', 'plugin:jest/recommended', 'plugin:jest/style', 'plugin:import/typescript', 'plugin:@typescript-eslint/recommended', ], rules: { 'prefer-destructuring': 'off', 'no-console': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': [ 'error', { args: 'all', argsIgnorePattern: '^_', }, ], '@typescript-eslint/ban-ts-comment': 'warn', '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', 'import/prefer-default-export': 'off', 'import/no-extraneous-dependencies': 'off', 'import/no-unresolved': 'off', // TypeScript handles this 'import/extensions': [ 'error', 'ignorePackages', { js: 'never', ts: 'never', }, ], // Styling. Remove after prettier upgrade 'object-curly-newline': 'off', 'function-paren-newline': 'off', 'implicit-arrow-linebreak': 'off', 'arrow-body-style': 'off', 'max-len': 'off', 'no-confusing-arrow': 'off', quotes: 'off', 'operator-linebreak': 'off', 'quote-props': 'off', indent: 'off', }, }, ], rules: { 'prefer-destructuring': 'off', 'no-console': 'error', 'no-unused-vars': [ 'error', { args: 'all', argsIgnorePattern: '^_', }, ], 'no-mixed-operators': [ 'error', { groups: [ ['&', '|', '^', '~', '<<', '>>', '>>>'], ['==', '!=', '===', '!==', '>', '>=', '<', '<='], ['&&', '||'], ['in', 'instanceof'], ], allowSamePrecedence: true, }, ], 'import/prefer-default-export': 'off', 'import/no-extraneous-dependencies': 'off', 'import/extensions': [ 'error', 'ignorePackages', { js: 'never', ts: 'never', }, ], // Styling. Remove after prettier upgrade 'object-curly-newline': 'off', 'function-paren-newline': 'off', 'implicit-arrow-linebreak': 'off', 'arrow-body-style': 'off', 'max-len': 'off', 'no-confusing-arrow': 'off', quotes: 'off', 'operator-linebreak': 'off', 'quote-props': 'off', indent: 'off', }, }; ================================================ FILE: .github/workflows/link_typecheck.yml ================================================ name: Lint & Typecheck on: pull_request: jobs: build: runs-on: macos-latest steps: - name: Checkout Repository uses: actions/checkout@v2 - name: Read .nvmrc run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" id: nvm - name: Setup Node.js uses: actions/setup-node@v1 with: node-version: '${{ steps.nvm.outputs.NVMRC }}' - name: Get Yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - name: Cache Yarn uses: actions/cache@v4 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} restore-keys: | yarn-${{ runner.os }}- - name: Install Dependencies run: yarn - name: Build Required Packages run: yarn bootstrap - name: Run ESLint run: yarn lint if: always() - name: Run TypeScript check run: yarn typecheck if: always() ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: push: branches: - main pull_request: jobs: build: runs-on: macos-latest steps: - name: Checkout Repository uses: actions/checkout@v2 - name: Read .nvmrc run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" id: nvm - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '${{ steps.nvm.outputs.NVMRC }}' - name: Install Nvim run: brew install nvim - name: Get Yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - name: Cache Yarn uses: actions/cache@v4 id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} restore-keys: | yarn-${{ runner.os }}- - name: Install Dependencies run: yarn - name: Build Required Packages run: yarn bootstrap - name: Run Tests run: yarn test --reporters="default" --reporters="jest-github-actions-reporter" --coverage - name: Upload snapshot diffs uses: actions/upload-artifact@v4 with: name: failed-image-snapshots path: packages/**/__diff_output__/* retention-days: 5 if: failure() - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 ================================================ FILE: .gitignore ================================================ # See https://help.github.com/ignore-files/ for more about ignoring files. **/node_modules/** **/build/** **/dist/** **/coverage/** **/tmp/** .DS_Store .env .nyc_output yarn-error.log __diff_output__ ================================================ FILE: .husky/.gitignore ================================================ _ ================================================ FILE: .husky/pre-commit ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" yarn lint-staged ================================================ FILE: .nvmrc ================================================ 20.9.0 ================================================ FILE: .prettierrc.js ================================================ module.exports = { trailingComma: 'all', printWidth: 100, singleQuote: true, }; ================================================ FILE: LICENSE ================================================ Copyright (c) 2018-present Igor Gladkoborodov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # VV VV is a Neovim client for macOS. A pure, fast, minimalistic Vim experience with good macOS integration. Optimized for speed and nice font rendering. ![VV screenshot](packages/electron/assets/screenshot.png) - Fast text render via WebGL. - OS integration: copy/paste, mouse, scroll. - Fullscreen support for native and simple (fast) mode. - All app settings configurable via Vimscript. - Command line launcher. - “Save All” dialog on quit and “Refresh” dialog on external changes. - Text zoom. VV is built on Electron. There are no barriers to porting it to Windows or Linux, or making plugins with Javascript, HTML, and CSS. ## Installation ### Install via Homebrew VV is available via Homebrew Cask: ``` $ brew install vv ``` NOTE: older versions of brew require a special command to install `vv` ``` $ brew cask install vv ``` It will also install Neovim (if it is not installed) and command line launcher `vv`. ### Download Or you can download the most recent release from the [Releases](https://github.com/vv-vim/vv/releases/latest) page. You need Neovim to run VV. You can install it via Homebrew: `$ brew install neovim`. Or you can find Neovim installation instructions here: [https://github.com/neovim/neovim/wiki/Installing-Neovim](https://github.com/neovim/neovim/wiki/Installing-Neovim). Neovim version 0.4.0 and higher is required. ### Build manually You can also build it manually. You will need [Node.js](https://nodejs.org/en/download/) and [Yarn](https://yarnpkg.com/lang/en/) installed. ``` $ git clone git@github.com:vv-vim/vv.git $ cd vv $ yarn $ yarn build:electron ``` This will generate a VV.app binary in the dist directory. Copy VV.app to your /Applications folder and add the CLI launcher `vv` to your `/usr/local/bin`. ## Command Line Launcher You can use the `vv` command to run VV in a Terminal. Install it via the `VV → Command Line Launcher...` menu item. VV will add the command to your `/usr/local/bin` folder. If you prefer another place, you can link the command manually: ``` ln -s -f /Applications/VV.app/Contents/Resources/bin/vv [dir from $PATH]/vv ``` Usage: `vv [options] [file ...]` Options are passed to `nvim`. You can check available options in nvim help: `nvim --help`. ## Settings You can setup VV-specific options via the `:VVset` command. It works the same as the vim built-in command `:set`. For example `:VVset nofullscreen` is the same as `:VVset fullscreen=0`. You can use `:help set` for syntax reference. - `fullscreen`, `fu`: Switch to fullscreen mode. You can also toggle fullscreen with `Cmd+Ctrl+F`. Default: `0`. - `simplefullscreen`, `sfu`: Use simple or standard fullscreen mode. Simple mode is faster than standard macOS fullscreen mode. It does not have any transition animation. Default: `1`. - `bold`: Allow bold font. You can completely disable bold even if the colorscheme uses it. Default: `1`. - `italic`: Allow italic. Default: `1`. - `underline`: Allow underline. Default: `1`. - `undercurl`: Allow undercurl. Default: `1`. - `strikethrough`: Allow strikethrough. Default: `1`. - `fontfamily`: Font family. Syntax is the same as CSS [`font-family`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family). You can use comma-separated list of fonts. It will use first installed font in the list and fallback to default monospace font if none of them installed. Spaces should be excaped by `\`. For example: `:VVset fontfamily=Menlo,\ Courier\ New`. Default: `monospace`. - `fontsize`: Font size in pixels. Default: `12`. - `lineheight`: Line height related to font size. Pixel value is `fontsize * lineheight`. Default: `1.25`. - `letterspacing`: Fine-tuning letter spacing in retina pixels. Can be a negative value. For retina screens the value is physical pixels. For non-retina screens it works differently: it divides the value by 2 and rounds it. For example, `:VVset letterspacing=1` will make characters 1 pixel wider on retina displays and will do nothing on non-retina displays. Value 2 is 2 physical pixels on retina and 1 physical pixel on non-retina. Default: `0`. - `windowwidth`, `width`: Window width. Can be a number in pixels or percentage of display width. - `windowheight`, `height`: Window height. - `windowleft`, `left`: Window position from left. Can be a number in pixels or a percentage. Percent values work the same as the `background-position` rule in CSS. For example: `25%` means that the vertical line on the window that is 25% from the left will be placed at the line that is 25% from the display's left. 0% — the very left, 100% — the very right, 50% — center. - `windowtop`, `top`: Window position top. - `quitoncloselastwindow`: Quit app on close last window. Default: `0`. - `autoupdateinterval`: Autoupdate interval in minutes. `0` — disable autoupdate. Default: `1440`, one day. - `openinproject`: Open file in existing VV instance if this file is located inside current directory of this instance. By default it will obey [`switchbuf`](https://neovim.io/doc/user/options.html#'switchbuf') option, but you can set `switchbuf` override as a value of this option, for example: `:VVset openinproject=newtab`. Possible values are: `1` use switchbuf, `0` open in new instance, any valid `switchbuf` value. Default: `1`. You can use these settings in your `init.vim` or change them any time. You can check if VV is loaded by checking the `g:vv` variable: ``` if exists('g:vv') VVset nobold VVset noitalic VVset windowheight=100% VVset windowwidth=60% VVset windowleft=0 VVset windowtop=0 endif ``` VV also sets `set termguicolors` on startup. ## Development First, you need start a Webpack watch process in a separate terminal: ``` yarn dev ``` Then you can run the app: ``` yarn start:electron ``` You can run tests with `yarn test` and ESLint with `yarn lint` commands. It is written on TypeScript, but it uses Babel to build. It does not check types during the build. If you want do do type check manually you can run it with `yarn typecheck`. ## Server You can run Neovim remotely in browser via VV Server. More info: [packages/server/README.md](packages/server/README.md) ## Browser Renderer [Browser Renderer](packages/browser-renderer/README.md) is a separate package used in Electron app and Server. ## Name The VV name comes from the bash shortcut `vv` that I use to start Vim. ## License VV is released under the [MIT License](https://opensource.org/licenses/MIT). ================================================ FILE: babel.config.json ================================================ { "presets": [["@babel/preset-env", { "modules": "commonjs" }], "@babel/preset-typescript"], "plugins": [ "@babel/plugin-proposal-optional-chaining", "@babel/plugin-proposal-class-properties", "@babel/plugin-transform-runtime", [ "module-resolver", { "root": ["."], "alias": { "src": "./src" } } ] ] } ================================================ FILE: codecov.yml ================================================ comment: layout: "reach, diff, flags, files" require_changes: false ignore: - "packages/browser-renderer/src/screen.ts" # Tested by Puppeteer ================================================ FILE: jest.config.js ================================================ module.exports = { clearMocks: true, testEnvironment: 'node', collectCoverageFrom: ['src/**/*.{ts,js}'], projects: ['/packages/*'], }; ================================================ FILE: package.json ================================================ { "name": "vv", "description": "Neovim GUI Client", "author": "Igor Gladkoborodov ", "keywords": [ "vim", "neovim", "client", "gui", "electron" ], "license": "MIT", "main": "./build/main.js", "sideEffects": false, "private": true, "workspaces": [ "packages/*" ], "scripts": { "bootstrap": "yarn build:nvim; yarn build:browser-renderer; yarn build:server", "build:nvim": "yarn workspace @vvim/nvim build", "build:browser-renderer": "yarn workspace @vvim/browser-renderer build", "build:electron": "yarn bootstrap; yarn workspace @vvim/electron build", "build:server": "yarn workspace @vvim/server build", "dev:nvim": "yarn workspace @vvim/nvim dev", "dev:browser-renderer": "yarn workspace @vvim/browser-renderer dev", "dev:electron": "yarn workspace @vvim/electron dev", "dev:server": "yarn workspace @vvim/server dev", "dev": "yarn bootstrap; npm-run-all --parallel dev:*", "start:electron": "yarn workspace @vvim/electron start", "start:server": "yarn workspace @vvim/server start", "lint": "eslint . --ext .js,.ts", "test": "jest", "typecheck": "tsc -p packages/browser-renderer; tsc -p packages/electron; tsc -p packages/server", "prepare": "husky install", "codegen": "babel-node -x \".ts\" scripts/codegen.ts" }, "devDependencies": { "@babel/core": "^7.24.0", "@babel/node": "^7.23.9", "@babel/plugin-proposal-class-properties": "^7.13.0", "@babel/plugin-proposal-optional-chaining": "^7.13.8", "@babel/plugin-transform-runtime": "^7.24.0", "@babel/preset-env": "^7.24.0", "@babel/preset-typescript": "^7.23.3", "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^4.15.2", "@typescript-eslint/parser": "^4.15.2", "babel-jest": "^30.1.1", "babel-loader": "^8.2.5", "babel-plugin-module-resolver": "^4.1.0", "codecov": "^3.8.1", "eslint": "^7.21.0", "eslint-config-airbnb-base": "^14.2.1", "eslint-config-prettier": "^8.1.0", "eslint-plugin-import": "^2.22.1", "eslint-plugin-jest": "^24.1.5", "eslint-plugin-prettier": "^3.3.1", "husky": "^5.2.0", "jest": "^30.1.1", "jest-github-actions-reporter": "^1.0.3", "lint-staged": "^10.5.4", "npm-run-all": "^4.1.5", "prettier": "^2.2.1", "regenerator": "^0.14.7", "ts-jest": "^29.4.1", "typescript": "^4.2.2", "webpack": "^5.90.3", "webpack-cli": "^5.1.4", "webpack-merge": "^5.10.0" }, "lint-staged": { "*.{ts,js,css,json,md}": [ "prettier --write" ] } } ================================================ FILE: packages/browser-renderer/README.md ================================================ # @vvim/browser-renderer This package is used to render Neovim in browser for [VV Electron App](../electron) and [VV server](../server). It is in active development, the API is not stable yet. ## Development Run in watch mode: ``` yarn dev ``` ================================================ FILE: packages/browser-renderer/babel.config.json ================================================ { "extends": "../../babel.config.json", "plugins": [ [ "module-resolver", { "root": ["."], "alias": { "src": "./src", "config": "./config" } } ] ] } ================================================ FILE: packages/browser-renderer/config/jest/afterEnv.js ================================================ const { toMatchImageSnapshot } = require('jest-image-snapshot'); expect.extend({ toMatchImageSnapshot }); ================================================ FILE: packages/browser-renderer/config/jest/globalSetup.js ================================================ /* eslint-disable no-underscore-dangle, no-undef */ import { setupTestServer } from './testServer'; const globalSetup = async () => { globalThis.__PUPPETEER_SERVER__ = await setupTestServer(); }; export default globalSetup; ================================================ FILE: packages/browser-renderer/config/jest/globalTeardown.js ================================================ /* eslint-disable no-underscore-dangle, no-undef */ import { teardownTestServer } from './testServer'; const globalTeardown = async () => { await teardownTestServer(globalThis.__PUPPETEER_SERVER__); }; export default globalTeardown; ================================================ FILE: packages/browser-renderer/config/jest/testServer.js ================================================ import { setup, teardown } from 'jest-dev-server'; // TODO: make it configurable export const PORT = 3001; export async function setupTestServer() { const server = await setup({ command: `PORT=${PORT} yarn start:server -u NONE`, launchTimeout: 10000, port: PORT, usedPortAction: 'kill', }); return server; } export async function teardownTestServer(server) { if (server) { await teardown(server); } } ================================================ FILE: packages/browser-renderer/config/webpack.config.js ================================================ const path = require('path'); const buildPath = path.resolve(__dirname, './../dist'); const config = { mode: 'development', entry: './src/index.ts', output: { path: buildPath, filename: 'index.js', libraryTarget: 'umd', }, target: 'web', devtool: 'eval-cheap-source-map', resolve: { extensions: ['.ts', '.js'], }, module: { rules: [ { test: /\.(js|ts)$/, exclude: /node_modules/, loader: 'babel-loader', }, ], }, }; module.exports = config; ================================================ FILE: packages/browser-renderer/config/webpack.prod.config.js ================================================ const { merge } = require('webpack-merge'); const webpackConfig = require('./webpack.config'); const prod = { mode: 'production', devtool: 'source-map', }; const webpackConfigProd = merge(webpackConfig, prod); module.exports = webpackConfigProd; ================================================ FILE: packages/browser-renderer/jest.config.js ================================================ module.exports = { testEnvironment: 'jsdom', clearMocks: true, moduleNameMapper: { '\\./src/(.*)': ['/src/$1'], 'config/(.*)': ['/config/$1'], }, testPathIgnorePatterns: ['/node_modules/', '/dist/'], setupFilesAfterEnv: ['/config/jest/afterEnv.js'], globalSetup: '/config/jest/globalSetup.js', globalTeardown: '/config/jest/globalTeardown.js', }; ================================================ FILE: packages/browser-renderer/package.json ================================================ { "name": "@vvim/browser-renderer", "version": "0.0.1", "description": "VV Browser Renderer", "author": "Igor Gladkoborodov ", "keywords": [ "vim", "neovim", "client", "gui", "renderer", "browser", "webgl" ], "homepage": "https://github.com/vv-vim/vv#readme", "license": "MIT", "main": "dist/index.js", "sideEffects": false, "scripts": { "test": "jest", "clean": "rm -rf dist/*", "build:types": "tsc -p tsconfig.declaration.json", "build:dev": "webpack --config ./config/webpack.config.js", "build:prod": "webpack --config ./config/webpack.prod.config.js", "build": "npm-run-all clean build:types build:prod", "dev": "npm-run-all --parallel \"build:types --watch\" \"build:dev --watch\"" }, "publishConfig": { "registry": "https://registry.yarnpkg.com" }, "repository": { "type": "git", "url": "git+https://github.com/vv-vim/vv.git" }, "bugs": { "url": "https://github.com/vv-vim/vv/issues" }, "browserslist": [ "defaults", "last 2 electron versions" ], "devDependencies": { "@types/express": "^4.17.11", "@types/jest-dev-server": "^5.0.3", "@types/jest-image-snapshot": "^6.4.0", "@types/lodash": "^4.14.168", "@types/node": "^16.0.0", "@types/ws": "^7.4.0", "jest-dev-server": "^11.0.0", "jest-environment-jsdom": "^30.1.1", "jest-image-snapshot": "^6.5.1", "jsdom": "^26.1.0", "puppeteer": "^24.17.1" }, "dependencies": { "@vvim/nvim": "0.0.1", "lodash": "^4.17.21", "ws": "^7.4.6" } } ================================================ FILE: packages/browser-renderer/src/__tests__/renderer.test.ts ================================================ import { EventEmitter } from 'events'; import initRenderer from 'src/renderer'; import Nvim from '@vvim/nvim'; import initScreen from 'src/screen'; import initKeyboard from 'src/input/keyboard'; import initMouse from 'src/input/mouse'; import hideMouseCursor from 'src/features/hideMouseCursor'; const mockTransport = new EventEmitter(); jest.mock('src/transport/transport', () => { return jest.fn().mockImplementation(() => mockTransport); }); jest.mock('@vvim/nvim'); jest.mock('src/screen', () => jest.fn(() => 'fakeScreen')); jest.mock('src/input/keyboard', () => jest.fn()); jest.mock('src/input/mouse', () => jest.fn()); jest.mock('src/features/hideMouseCursor', () => jest.fn()); describe('renderer', () => { const mockedNvim = (Nvim as unknown) as jest.Mock; beforeEach(() => { mockTransport.removeAllListeners(); initRenderer(); }); test('init screen', () => { mockTransport.emit('initRenderer', 'settings'); expect(initScreen).toHaveBeenCalledWith({ nvim: mockedNvim.mock.instances[0], settings: 'settings', transport: mockTransport, }); }); test('init nvim', () => { mockTransport.emit('initRenderer', 'settings'); expect(Nvim).toHaveBeenCalledWith(mockTransport, true); }); test('init keyboard', () => { mockTransport.emit('initRenderer', 'settings'); expect(initKeyboard).toHaveBeenCalledWith({ nvim: mockedNvim.mock.instances[0], screen: 'fakeScreen', }); }); test('init mouse', () => { mockTransport.emit('initRenderer', 'settings'); expect(initMouse).toHaveBeenCalledWith({ nvim: mockedNvim.mock.instances[0], screen: 'fakeScreen', }); }); test('init hideMouseCursor', () => { mockTransport.emit('initRenderer', 'settings'); expect(hideMouseCursor).toHaveBeenCalledWith(); }); }); ================================================ FILE: packages/browser-renderer/src/__tests__/screen.test.ts ================================================ /** @jest-environment node */ import puppeteer from 'puppeteer'; import { PORT } from 'config/jest/testServer'; import type { Browser, Page } from 'puppeteer'; describe('Screen', () => { jest.setTimeout(30000); let browser: Browser; let page: Page; beforeAll(async () => { browser = await puppeteer.launch({ headless: true, slowMo: 10, args: ['--no-sandbox', '--disable-setuid-sandbox'], }); }); afterAll(async () => { // await browser.close(); }); beforeEach(async () => { page = await browser.newPage(); await page.setViewport({ width: 300, height: 200, deviceScaleFactor: 2, }); await page.goto(`http://localhost:${PORT}`); await page.waitForSelector('input'); await page.keyboard.type(':VVset fontfamily=Courier\\ New'); await page.keyboard.press('Enter'); }); afterEach(async () => { await page.close(); }); it('match snapshot', async () => { await page.keyboard.type('iHello'); await page.keyboard.press('Escape'); const image = await page.screenshot(); expect(image).toMatchImageSnapshot(); }); it('redraw screen on default_colors_set', async () => { await page.keyboard.type(':colorscheme desert'); await page.keyboard.press('Enter'); const image = await page.screenshot(); expect(image).toMatchImageSnapshot(); }); test('show undercurl behind the text', async () => { await page.keyboard.type(':set filetype=javascript'); await page.keyboard.press('Enter'); await page.keyboard.type(':VVset lineheight=1'); await page.keyboard.press('Enter'); await page.keyboard.type(':syntax on'); await page.keyboard.press('Enter'); await page.keyboard.type(':hi Comment gui=undercurl guifg=white guisp=red'); await page.keyboard.press('Enter'); await page.keyboard.type('i// Hey!'); const image = await page.screenshot(); expect(image).toMatchImageSnapshot(); }); test('overlap chars', async () => { await page.keyboard.type(':VVset letterspacing=-8'); await page.keyboard.press('Enter'); await page.keyboard.type( 'i\n\n\nO O O O O O O O O O O OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO', ); await page.keyboard.press('Escape'); await page.keyboard.type('hhhi '); await page.keyboard.press('Escape'); await page.keyboard.type('hhh'); const image = await page.screenshot(); expect(image).toMatchImageSnapshot(); await page.keyboard.type(':vs'); await page.keyboard.press('Enter'); await page.keyboard.type(':vs'); await page.keyboard.press('Enter'); await page.keyboard.type('i'); await page.keyboard.press('Escape'); await page.mouse.move(150, 100); await page.mouse.wheel({ deltaY: 100 }); const image1 = await page.screenshot(); expect(image1).toMatchImageSnapshot(); }); }); ================================================ FILE: packages/browser-renderer/src/features/hideMouseCursor.ts ================================================ /** * Hides mouse cursor when you start typing. Shows it again when you move mouse. */ function showCursor() { document.body.style.cursor = 'auto'; document.addEventListener('keydown', hideCursor); // eslint-disable-line no-use-before-define document.removeEventListener('mousemove', showCursor); } function hideCursor(): void { document.body.style.cursor = 'none'; document.addEventListener('mousemove', showCursor); document.removeEventListener('keydown', hideCursor); } export default hideCursor; ================================================ FILE: packages/browser-renderer/src/index.ts ================================================ // Only use relative imports here because https://github.com/microsoft/TypeScript/issues/32999#issuecomment-523558695 import renderer from './renderer'; export default renderer; ================================================ FILE: packages/browser-renderer/src/input/__tests__/keyboard.test.ts ================================================ import initKeyboard from 'src/input/keyboard'; import Nvim from '@vvim/nvim'; import { Screen } from 'src/screen'; describe('Keyboard input', () => { const nvimOn = jest.fn(); const screen = ({ getCursorElement: jest.fn(), } as unknown) as Screen; const nvim = ({ input: jest.fn(), on: nvimOn, } as unknown) as Nvim; const simulateKeyDown = (options: KeyboardEventInit) => { const event = new KeyboardEvent('keydown', options); document.dispatchEvent(event); }; const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); beforeEach(() => { initKeyboard({ screen, nvim }); }); afterEach(() => { const rootElm = document.documentElement; rootElm.innerHTML = ''; addEventListenerSpy.mock.calls.forEach(([event, callback, options]) => document.removeEventListener(event, callback, options), ); }); describe('key input', () => { test('sends key value to nvim input', () => { simulateKeyDown({ key: 'i' }); expect(nvim.input).toHaveBeenCalledWith('i'); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('special key', () => { simulateKeyDown({ code: 'Insert' }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('special key with Shift', () => { simulateKeyDown({ code: 'Insert', shiftKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('special key with modifier', () => { simulateKeyDown({ code: 'Insert', altKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('< key', () => { simulateKeyDown({ key: '<', shiftKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('\\ key', () => { simulateKeyDown({ key: '\\' }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('| key', () => { simulateKeyDown({ key: '|' }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test.todo('TODO: test all special keys'); }); describe('motifiers', () => { test('CTRL key adds modifier', () => { simulateKeyDown({ key: 'i', ctrlKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('Option key adds modifier', () => { simulateKeyDown({ key: 'i', altKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('CMD key adds modifier', () => { simulateKeyDown({ key: 'i', metaKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('Shift key does not add modifier without other motifiers', () => { simulateKeyDown({ key: 'I', shiftKey: true }); expect(nvim.input).toHaveBeenCalledWith('I'); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('Shift adds modifier with other motifiers', () => { simulateKeyDown({ key: 'i', ctrlKey: true, shiftKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('multiple motifiers', () => { simulateKeyDown({ key: 'i', ctrlKey: true, metaKey: true, altKey: true, shiftKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); }); describe('Option key modifier', () => { test("Map Dead key with Option to it's latin value", () => { simulateKeyDown({ key: 'Dead', code: 'KeyI', altKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('Skip Dead key if Option is not pressed', () => { simulateKeyDown({ key: 'Dead', code: 'KeyI' }); expect(nvim.input).toHaveBeenCalledTimes(0); }); test('Skip Dead key if there are no latin code for it', () => { simulateKeyDown({ key: 'Dead', code: 'NotKeyI', altKey: true }); expect(nvim.input).toHaveBeenCalledTimes(0); }); test('Adds A- modifier for non-Dead key', () => { simulateKeyDown({ key: '∆', altKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); describe('with input mode', () => { beforeEach(() => { nvimOn.mock.calls[0][1]([['mode_change', ['insert']]]); }); test('Does not add A- modifier', () => { simulateKeyDown({ key: '∆', code: 'KeyJ', altKey: true }); expect(nvim.input).toHaveBeenCalledWith('∆'); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('Does not add A- modifier with Shift', () => { simulateKeyDown({ key: 'Ô', code: 'KeyJ', altKey: true, shiftKey: true }); expect(nvim.input).toHaveBeenCalledWith('Ô'); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('Adds A- modifier with Control', () => { simulateKeyDown({ key: '∆', code: 'KeyJ', altKey: true, ctrlKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); test('Adds A- modifier with Command', () => { simulateKeyDown({ key: '∆', code: 'KeyJ', altKey: true, metaKey: true }); expect(nvim.input).toHaveBeenCalledWith(''); expect(nvim.input).toHaveBeenCalledTimes(1); }); }); }); describe('focus input', () => { let input: HTMLInputElement; let focusSpy: jest.SpyInstance; let blurSpy: jest.SpyInstance; beforeEach(() => { input = document.getElementsByTagName('input')[0]; focusSpy = jest.spyOn(input, 'focus'); blurSpy = jest.spyOn(input, 'blur'); }); test('focus input on insert mode', () => { nvimOn.mock.calls[0][1]([['mode_change', ['insert']]]); expect(focusSpy).toHaveBeenCalled(); }); test('focus input on cmdline_normal mode', () => { nvimOn.mock.calls[0][1]([['mode_change', ['cmdline_normal']]]); expect(focusSpy).toHaveBeenCalled(); }); test('blurs input on other modes', () => { nvimOn.mock.calls[0][1]([['mode_change', ['normal']]]); expect(blurSpy).toHaveBeenCalled(); }); }); }); ================================================ FILE: packages/browser-renderer/src/input/keyboard.ts ================================================ import type { Nvim } from '@vvim/nvim'; import { Screen } from 'src/screen'; // :help keyCode const specialKey = ({ key, code }: KeyboardEvent): string => (({ Insert: 'Insert', Numpad0: 'k0', Numpad1: 'k1', Numpad2: 'k2', Numpad3: 'k3', Numpad4: 'k4', Numpad5: 'k5', Numpad6: 'k6', Numpad7: 'k7', Numpad8: 'k8', Numpad9: 'k9', NumpadAdd: 'kPlus', NumpadSubtract: 'kMinus', NumpadMultiply: 'kMultiply', NumpadDivide: 'kDivide', NumpadEnter: 'kEnter', NumpadDecimal: 'kPoint', Escape: 'Esc', Backspace: 'BS', Delete: 'Del', Enter: 'CR', Tab: 'Tab', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', PageUp: 'PageUp', PageDown: 'PageDown', Home: 'Home', End: 'End', F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4', F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8', F9: 'F9', F10: 'F10', F11: 'F11', F12: 'F12', } as Record)[code] || ({ '<': 'lt', '\\': 'Bslash', '|': 'Bar', } as Record)[key]); const skip = (key: string) => (({ Shift: true, Control: true, Alt: true, Meta: true, CapsLock: true, } as Record)[key]); export const modifierPrefix = ( { metaKey, altKey, ctrlKey }: KeyboardEvent | MouseEvent, insertMode?: boolean, ): string => { if (insertMode && altKey && !ctrlKey && !metaKey) { return ''; } return `${metaKey ? 'D-' : ''}${altKey ? 'A-' : ''}${ctrlKey ? 'C-' : ''}`; }; export const shiftPrefix = ({ shiftKey, key }: KeyboardEvent): string => shiftKey && key !== '<' ? 'S-' : ''; /** * Filter hotkeys from menu. * TODO: Make it customizable and make it work differently in browser and electron app. */ const filterResult = (result: string) => !({ '': true, // Cmd+C '': true, // Cmd+V '': true, // Cmd+A: "Select all" menu item '': true, // Cmd+Plus: "Zoom In" menu item '': true, // Cmd+-: "Zoom Out" menu item '': true, // Cmd+0: "Actual Size" menu item '': true, // Cmd+Ctrl+F: "Toggle Full Screen" menu item '': true, // Cmd+M: "Minimize" menu item '': true, // Cmd+H: Hide window '': true, // Cmd+Q: Quit '': true, // Cmd+O: Open file '': true, // Cmd+N: New window '': true, // Cmd+W: Close window } as Record)[result] && result; // https://github.com/rhysd/NyaoVim/issues/87 const replaceResult = (result: string) => (({ '': '', '': '', '': '', } as Record)[result] || result); const eventKeyCode = (event: KeyboardEvent, insertMode?: boolean): string | null => { const { key } = event; if (skip(key)) return null; // Handle Alt + modifier key input (for example Alt + i) let deadKey; if (key === 'Dead') { if (!insertMode && event.altKey && event.code.match(/^Key[A-Z]$/)) { deadKey = event.code[3].toLowerCase(); } else { return null; } } const modifier = modifierPrefix(event, insertMode); const shift = shiftPrefix(event); const special = specialKey(event); const keyCode = deadKey || special || key; const result = modifier || special ? `<${modifier}${shift}${keyCode}>` : keyCode; const filteredResult = filterResult(result); if (!filteredResult) { return null; } return replaceResult(filteredResult); }; const initKeyboard = ({ nvim, screen }: { nvim: Nvim; screen: Screen }): void => { const { getCursorElement } = screen; let disableNextInput = false; let inputKey: string | null = null; let isComposing = false; let compositionValue = null; let insertMode = false; const input = document.createElement('input'); input.style.position = 'absolute'; input.style.opacity = '0'; input.style.left = '0'; input.style.top = '0'; input.style.width = '0'; input.style.height = '0'; (getCursorElement() || document.getElementsByTagName('body')[0]).appendChild(input); const handleKeydown = async (event: KeyboardEvent) => { disableNextInput = true; if (!isComposing) { inputKey = eventKeyCode(event, insertMode); if (inputKey) { nvim.input(inputKey); } } }; // Non-keyboard input. For example insert emoji. const handleInput = (event: InputEvent) => { if (disableNextInput || isComposing) { disableNextInput = false; return; } if (event.data) { nvim.input(event.data); } }; // Composition input for logograms or diacritical signs. Also works for speech input. const handleCompositionStart = () => { isComposing = true; compositionValue = inputKey || ''; }; const handleCompositionEnd = () => { isComposing = false; }; const handleCompositionUpdate = (event: CompositionEvent) => { nvim.input(`${''.repeat(compositionValue.length)}${event.data}`); compositionValue = event.data; }; document.addEventListener('keydown', handleKeydown); // @ts-expect-error input event type is incorrect input.addEventListener('input', handleInput); input.addEventListener('compositionstart', handleCompositionStart); input.addEventListener('compositionupdate', handleCompositionUpdate); input.addEventListener('compositionend', handleCompositionEnd); // Enable composition input only for insert and command-line modes. Enabling if for other modes // is tricky. `preventDefault` does not work for compositionstart, so we need to blur/focus input // element for this. nvim.on('redraw', (args) => { args.forEach((arg) => { if (arg[0] === 'mode_change') { const [mode] = arg[1]; // https://github.com/neovim/neovim/blob/master/src/nvim/cursor_shape.c#L18 if (['insert', 'cmdline_normal'].includes(mode)) { insertMode = true; input.focus(); } else { insertMode = false; input.blur(); } } }); }); }; export default initKeyboard; ================================================ FILE: packages/browser-renderer/src/input/mouse.ts ================================================ import throttle from 'lodash/throttle'; import { modifierPrefix } from 'src/input/keyboard'; import { Screen } from 'src/screen'; import type Nvim from '@vvim/nvim'; const GRID = 0; const SCROLL_STEP_X = 6; const SCROLL_STEP_Y = 3; const MOUSE_BUTTON = { 0: 'left', 1: 'middle', 2: 'right', WHEEL: 'wheel', }; const ACTION = { UP: 'up', DOWN: 'down', LEFT: 'left', RIGHT: 'right', PRESS: 'press', DRAG: 'drag', RELEASE: 'release', } as const; type Action = typeof ACTION[keyof typeof ACTION]; // const initMouse = ({ screenCoords }: Screen, nvim: Nvim): void => { const initMouse = ({ screen, nvim }: { screen: Screen; nvim: Nvim }): void => { const { screenCoords } = screen; let scrollDeltaX = 0; let scrollDeltaY = 0; let mouseCoords: [number, number] = [0, 0]; let mouseButtonDown: boolean; const mouseCoordsChanged = (event: MouseEvent) => { const newCoords = screenCoords(event.clientX, event.clientY); if (newCoords[0] !== mouseCoords[0] || newCoords[1] !== mouseCoords[1]) { mouseCoords = newCoords; return true; } return false; }; const buttonName = (event: MouseEvent) => // @ts-expect-error TODO event.type === 'wheel' ? MOUSE_BUTTON.WHEEL : MOUSE_BUTTON[event.button]; const mouseInput = (event: MouseEvent, action: Action) => { mouseCoordsChanged(event); const [col, row] = screenCoords(event.clientX, event.clientY); const button = buttonName(event); const modifier = modifierPrefix(event); nvim.inputMouse(button, action, modifier, GRID, row, col); }; const calculateScroll = (event: MouseEvent) => { let [scrollX, scrollY] = screenCoords(Math.abs(scrollDeltaX), Math.abs(scrollDeltaY)); scrollX = Math.floor(scrollX / SCROLL_STEP_X); scrollY = Math.floor(scrollY / SCROLL_STEP_Y); if (scrollY === 0 && scrollX === 0) return; if (scrollY !== 0) { mouseInput(event, scrollDeltaY > 0 ? ACTION.DOWN : ACTION.UP); scrollDeltaY = 0; } if (scrollX !== 0) { mouseInput(event, scrollDeltaX > 0 ? ACTION.RIGHT : ACTION.LEFT); scrollDeltaX = 0; } }; const handleMousewheel = (event: WheelEvent) => { const { deltaX, deltaY } = event; if (scrollDeltaY * deltaY < 0) scrollDeltaY = 0; scrollDeltaX += deltaX; scrollDeltaY += deltaY; calculateScroll(event); }; const handleMousedown = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); mouseButtonDown = true; mouseInput(event, ACTION.PRESS); }; const handleMouseup = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); mouseButtonDown = false; mouseInput(event, ACTION.RELEASE); }; const handleMousemove = (event: MouseEvent) => { if (mouseButtonDown) { event.preventDefault(); event.stopPropagation(); if (mouseCoordsChanged(event)) mouseInput(event, ACTION.DRAG); } }; nvim.command('set mouse=a'); // Enable mouse events document.addEventListener('mousedown', handleMousedown); document.addEventListener('mouseup', handleMouseup); document.addEventListener('mousemove', throttle(handleMousemove, 50)); document.addEventListener('wheel', handleMousewheel); }; export default initMouse; ================================================ FILE: packages/browser-renderer/src/lib/__tests__/getColor.test.ts ================================================ import { getColor, getColorNum } from 'src/lib/getColor'; describe('getColor', () => { test('0 is black', () => { expect(getColor(0)).toBe('rgb(0,0,0)'); }); test('0xffffff is white', () => { expect(getColor(0xffffff)).toBe('rgb(255,255,255)'); }); test('0x333333 is gray', () => { expect(getColor(0x333333)).toBe('rgb(51,51,51)'); }); test('0x003300 is rgb(0,51,0)', () => { expect(getColor(0x003300)).toBe('rgb(0,51,0)'); }); }); describe('getColorNum', () => { test('rgb(0, 0, 0) is 0', () => { expect(getColorNum('rgb(0,0,0)')).toBe(0); }); test('rgb(255,255,255) is 0xffffff', () => { expect(getColorNum('rgb(255,255,255)')).toBe(0xffffff); }); test('rgb(51,51,51) is 0x333333', () => { expect(getColorNum('rgb(51,51,51)')).toBe(0x333333); }); test('rgb(0,51,0) is 0x00ff00', () => { expect(getColorNum('rgb(0,51,0)')).toBe(0x003300); }); test('returns undefined for undefined param', () => { expect(getColorNum()).toBeUndefined(); }); }); ================================================ FILE: packages/browser-renderer/src/lib/getColor.ts ================================================ /* eslint-disable no-bitwise */ import memoize from 'lodash/memoize'; /** * Get color by number, for example hex number `0xFF0000` becomes `rgb(255,0,0)` * @param color Color in number * @param defaultColor Use default color if color is undefined or -1 */ export const getColor = (color: number | undefined, defaultColor?: string): string | undefined => { if (typeof color !== 'number' || color === -1) return defaultColor; return `rgb(${(color >> 16) & 0xff},${(color >> 8) & 0xff},${color & 0xff})`; }; /** * Get color number from string, for example `rgb(255,0,0)` becomes `0xFF0000` * @param color Color in rgb string */ export const getColorNum = memoize((color?: string): number | undefined => { if (color) { const [r, g, b] = color .replace(/([^0-9,])/g, '') .split(',') .map((s) => parseInt(s, 10)); return (r << 16) + (g << 8) + b; } return undefined; }); ================================================ FILE: packages/browser-renderer/src/lib/isWeb.ts ================================================ const isWeb = (): boolean => window.location.protocol === 'http:' || window.location.protocol === 'https:'; export default isWeb; ================================================ FILE: packages/browser-renderer/src/preloaded/electron.ts ================================================ export interface PreloadedIpcRenderer { /** * Listens to `channel`, when a new message arrives `listener` would be called with * `listener(args...)`. */ on(channel: string, listener: (...args: any[]) => void): this; /** * Adds a one time `listener` function for the event. This `listener` is invoked * only the next time a message is sent to `channel`, after which it is removed. */ removeListener(channel: string, listener: (...args: any[]) => void): this; /** * Send an asynchronous message to the main process via `channel`, along with * arguments. Arguments will be serialized with the Structured Clone Algorithm, * just like `window.postMessage`, so prototype chains will not be included. * Sending Functions, Promises, Symbols, WeakMaps, or WeakSets will throw an * exception. * * > **NOTE**: Sending non-standard JavaScript types such as DOM objects or special * Electron objects is deprecated, and will begin throwing an exception starting * with Electron 9. * * The main process handles it by listening for `channel` with the `ipcMain` * module. */ send(channel: string, ...args: any[]): void; } declare global { interface Window { electron: { ipcRenderer: PreloadedIpcRenderer; }; } } export const { ipcRenderer } = window.electron || {}; ================================================ FILE: packages/browser-renderer/src/renderer.ts ================================================ import Nvim from '@vvim/nvim'; import { Settings } from 'src/types'; import Transport from 'src/transport/transport'; import initScreen from 'src/screen'; import initKeyboard from 'src/input/keyboard'; import initMouse from 'src/input/mouse'; import hideMouseCursor from 'src/features/hideMouseCursor'; /** * Browser renderer */ const renderer = (): void => { const transport = new Transport(); const initRenderer = (settings: Settings) => { const nvim = new Nvim(transport, true); const screen = initScreen({ nvim, settings, transport }); initKeyboard({ nvim, screen }); initMouse({ nvim, screen }); hideMouseCursor(); }; transport.on('initRenderer', initRenderer); }; export default renderer; ================================================ FILE: packages/browser-renderer/src/screen.ts ================================================ import isEqual from 'lodash/isEqual'; import emojiRegex from 'emoji-regex'; import { getColor } from 'src/lib/getColor'; import type { Settings } from 'src/types'; import type { Nvim, Transport, UiEventsHandlers, UiEventsArgs, ModeInfo, HighlightAttrs, } from '@vvim/nvim'; export type Screen = { screenCoords: (width: number, height: number) => [number, number]; getCursorElement: () => HTMLDivElement; }; type CalculatedProps = { bgColor: string; fgColor: string; spColor?: string; hiItalic: boolean; hiBold: boolean; hiUnderline: boolean; hiUndercurl: boolean; hiStrikethrough: boolean; }; type HighlightProps = { calculated?: CalculatedProps; value?: HighlightAttrs; }; type HighlightTable = Record; type Char = { bitmap?: HTMLCanvasElement; char?: string | null; hlId?: number; }; const DEFAULT_FONT_FAMILY = 'Courier New'; const DEFAULT_FG_COLOR = 'rgb(255,255,255)'; const DEFAULT_BG_COLOR = 'rgb(0,0,0)'; const DEFAULT_SP_COLOR = 'rgb(255,255,255)'; const DEFAULT_FONT_SIZE = 12; const DEFAULT_LINE_HEIGHT = 1.25; const DEFAULT_LETTER_SPACING = 0; let cursorAnimation: Animation; const isEmoji = (char: string): boolean => { const regex = emojiRegex(); return !!char.match(regex); }; const screen = ({ settings, transport, nvim, }: { settings: Settings; transport: Transport; nvim: Nvim; }): Screen => { let screenContainer: HTMLDivElement; let cursorEl: HTMLDivElement; let canvasEl: HTMLCanvasElement; let context: CanvasRenderingContext2D; let cursorCanvasEl: HTMLCanvasElement; let cursorContext: CanvasRenderingContext2D; let cursorPosition: [number, number] = [0, 0]; let cursorChar: string; let scale: number; let charWidth: number; let charHeight: number; let fontFamily = DEFAULT_FONT_FAMILY; let fontSize = DEFAULT_FONT_SIZE; let lineHeight = DEFAULT_LINE_HEIGHT; let letterSpacing = DEFAULT_LETTER_SPACING; let cols: number; let rows: number; let modeInfoSet: Record; let mode: string; let showBold = true; let showItalic = true; let showUnderline = true; let showUndercurl = true; let showStrikethrough = true; let chars: Char[][] = []; const highlightTable: HighlightTable = { '0': { calculated: { bgColor: DEFAULT_BG_COLOR, fgColor: DEFAULT_FG_COLOR, spColor: DEFAULT_SP_COLOR, hiItalic: false, hiBold: false, hiUnderline: false, hiUndercurl: false, hiStrikethrough: false, }, }, // Inverted default color for cursor '-1': { calculated: { bgColor: DEFAULT_FG_COLOR, fgColor: DEFAULT_BG_COLOR, spColor: DEFAULT_SP_COLOR, hiItalic: false, hiBold: false, hiUnderline: false, hiUndercurl: false, hiStrikethrough: false, }, }, }; let isResizing = false; let isResizingTimeout: NodeJS.Timeout | undefined; const setResizing = () => { isResizing = true; if (isResizingTimeout) { clearTimeout(isResizingTimeout); isResizingTimeout = undefined; } isResizingTimeout = setTimeout(() => { isResizing = false; }, 300); }; const getCursorElement = (): HTMLDivElement => cursorEl; const initCursor = () => { cursorEl = document.createElement('div'); cursorEl.style.position = 'absolute'; cursorEl.style.zIndex = '100'; cursorEl.style.top = '0'; cursorEl.style.left = '0'; screenContainer.appendChild(cursorEl); cursorCanvasEl = document.createElement('canvas'); cursorCanvasEl.style.position = 'absolute'; cursorCanvasEl.style.top = '0px'; cursorCanvasEl.style.left = '0px'; cursorContext = cursorCanvasEl.getContext('2d', { alpha: true }) as CanvasRenderingContext2D; cursorEl.appendChild(cursorCanvasEl); }; const initScreen = () => { screenContainer = document.createElement('div'); document.body.appendChild(screenContainer); screenContainer.style.position = 'absolute'; screenContainer.style.left = '0'; screenContainer.style.top = '0'; screenContainer.style.transformOrigin = '0 0'; canvasEl = document.createElement('canvas'); canvasEl.style.position = 'absolute'; canvasEl.style.top = '0px'; canvasEl.style.left = '0px'; context = canvasEl.getContext('2d', { alpha: false }) as CanvasRenderingContext2D; screenContainer.appendChild(canvasEl); }; const RETINA_SCALE = 2; const charsCache: Map = new Map(); const isRetina = () => window.devicePixelRatio === RETINA_SCALE; const scaledLetterSpacing = () => { if (isRetina() || letterSpacing === 0) { return letterSpacing; } return letterSpacing > 0 ? Math.floor(letterSpacing / RETINA_SCALE) : Math.ceil(letterSpacing / RETINA_SCALE); }; const scaledFontSize = () => fontSize * scale; const measureCharSize = () => { const char = document.createElement('span'); char.innerHTML = '0'; char.style.fontFamily = fontFamily; char.style.fontSize = `${scaledFontSize()}px`; char.style.lineHeight = `${Math.round(scaledFontSize() * lineHeight)}px`; char.style.position = 'absolute'; char.style.left = '-1000px'; char.style.top = '0'; screenContainer.appendChild(char); const oldCharWidth = charWidth; const oldCharHeight = charHeight; charWidth = Math.max(char.offsetWidth + scaledLetterSpacing(), 1); charHeight = char.offsetHeight; if (oldCharWidth !== charWidth || oldCharHeight !== charHeight) { cursorEl.style.width = `${charWidth}px`; cursorEl.style.height = `${charHeight}px`; cursorCanvasEl.width = charWidth; cursorCanvasEl.height = charHeight; charsCache.clear(); } screenContainer.removeChild(char); }; const font = (p: CalculatedProps, isEmojiFont?: boolean) => [ p.hiItalic ? 'italic' : '', p.hiBold ? 'bold' : '', `${scaledFontSize()}px`, isEmojiFont ? 'Apple Color Emoji' : fontFamily, ].join(' '); const getCharBitmap = (char: string, hlId: number) => { // eslint-disable-next-line const p = highlightTable[hlId].calculated!; const key = `${char}:${p.bgColor}:${p.fgColor}:${ p.hiUndercurl || p.hiUnderline ? p.spColor : '-' }:${p.hiItalic}:${p.hiBold}:${p.hiUndercurl}:${p.hiStrikethrough}`; if (!charsCache.has(key)) { // TODO: worker maybe? // const charCanvas = new OffscreenCanvas(charWidth * 3, charHeight); const charCanvas = document.createElement('canvas'); charCanvas.width = charWidth * 3; charCanvas.height = charHeight; // eslint-disable-next-line const charCtx = charCanvas.getContext('2d', { alpha: true, }) as CanvasRenderingContext2D; if (p.hiUndercurl) { charCtx.strokeStyle = p.spColor as string; charCtx.lineWidth = scaledFontSize() * 0.08; const x = charWidth; const y = charHeight - (scaledFontSize() * 0.08) / 2; const h = charHeight * 0.2; // Height of the wave charCtx.beginPath(); charCtx.moveTo(x, y); charCtx.bezierCurveTo(x + x / 4, y, x + x / 4, y - h / 2, x + x / 2, y - h / 2); charCtx.bezierCurveTo(x + (x / 4) * 3, y - h / 2, x + (x / 4) * 3, y, x + x, y); charCtx.stroke(); } charCtx.fillStyle = p.fgColor; charCtx.font = font(p, isEmoji(char)); charCtx.textAlign = 'left'; charCtx.textBaseline = 'middle'; if (char) { charCtx.fillText( char, Math.round(scaledLetterSpacing() / 2) + charWidth, Math.round(charHeight / 2), ); } if (p.hiUnderline) { charCtx.strokeStyle = p.fgColor; charCtx.lineWidth = scale; charCtx.beginPath(); charCtx.moveTo(charWidth, charHeight - scale); charCtx.lineTo(charWidth * 2, charHeight - scale); charCtx.stroke(); } if (p.hiStrikethrough) { charCtx.strokeStyle = p.fgColor; charCtx.lineWidth = scale; charCtx.beginPath(); charCtx.moveTo(charWidth, charHeight * 0.5); charCtx.lineTo(charWidth * 2, charHeight * 0.5); charCtx.stroke(); } charsCache.set(key, charCanvas); } // eslint-disable-next-line return charsCache.get(key)!; }; const initChar = (i: number, j: number) => { if (!chars[i]) chars[i] = []; if (!chars[i][j]) chars[i][j] = {}; }; const printBackground = (hlId: number, i: number, j: number, length: number) => { const fillStyle = highlightTable[hlId]?.calculated?.bgColor; if (fillStyle) { context.fillStyle = fillStyle; // Add an extra BG if this is the edge of the screen to make it look nicer const isEndOfLine = j + length === cols; const bgWidth = isEndOfLine ? (length + 1) * charWidth : length * charWidth; context.fillRect(j * charWidth, i * charHeight, bgWidth, charHeight); } }; const printChar = (i: number, j: number, char: string, hlId: number) => { initChar(i, j); chars[i][j].char = char; chars[i][j].hlId = hlId; chars[i][j].bitmap = getCharBitmap(char, hlId); context.drawImage( chars[i][j].bitmap as HTMLCanvasElement, 0, 0, charWidth * 3, charHeight, (j - 1) * charWidth, i * charHeight, charWidth * 3, charHeight, ); }; // https://github.com/neovim/neovim/blob/5a11e55/runtime/doc/ui.txt#L237 const redrawDefaultColors = () => { context.fillStyle = highlightTable[0]?.calculated?.bgColor || DEFAULT_BG_COLOR; context.fillRect(0, 0, canvasEl.width, canvasEl.height); for (let i = 0; i <= rows; i += 1) { if (chars[i]) { for (let j = 0; j <= cols; j += 1) { const redrawChar = chars[i][j]; if (redrawChar) { const { hlId } = redrawChar; if (hlId !== undefined && hlId > 0) { const { foreground, background, special } = highlightTable[hlId].value || {}; if (!foreground || !background || !special) { printBackground(hlId, i, j, 1); } } } } } } for (let i = 0; i <= rows; i += 1) { if (chars[i]) { for (let j = 0; j <= cols; j += 1) { const redrawChar = chars[i][j]; if (redrawChar) { const { hlId, char } = redrawChar; if (hlId !== undefined && typeof char === 'string' && char !== ' ') { const { foreground, background, special } = highlightTable[hlId].value || {}; if (!foreground || !background || !special) { chars[i][j].bitmap = undefined; printChar(i, j, char, hlId); } } } } } } }; const redrawCursor = () => { const m = modeInfoSet && modeInfoSet[mode]; // TODO: check if cursor changed (char, hlId, etc) if (!m) return; const hlId = m.attr_id === 0 ? -1 : m.attr_id; const { width, height } = cursorCanvasEl; const fillStyle = highlightTable[hlId]?.calculated?.bgColor; if (fillStyle) { cursorContext.fillStyle = fillStyle; } if (m.cursor_shape === 'block') { cursorChar = chars?.[cursorPosition[0]]?.[cursorPosition[1]]?.char || ' '; cursorContext.fillRect(0, 0, charWidth, charHeight); const cursorBitmap = getCharBitmap(cursorChar, hlId); cursorContext.drawImage(cursorBitmap, -charWidth, 0); } else if (m.cursor_shape === 'vertical') { const curWidth = m.cell_percentage ? Math.max(scale, Math.round((charWidth / 100) * m.cell_percentage)) : scale; cursorContext.clearRect(0, 0, width, height); cursorContext.fillRect(0, 0, curWidth, charHeight); } else if (m.cursor_shape === 'horizontal') { const curHeight = m.cell_percentage ? Math.max(scale, Math.round((charHeight / 100) * m.cell_percentage)) : scale; // TODO: test cursorContext.clearRect(0, 0, width, height); cursorContext.fillRect(0, charHeight - curHeight, charWidth, curHeight); } // Cursor blink if (cursorAnimation) { cursorAnimation.cancel(); } if (m.blinkoff && m.blinkon) { const offset = m.blinkon / (m.blinkon + m.blinkoff); cursorAnimation = cursorEl.animate( [ { opacity: 1, offset: 0 }, { opacity: 1, offset }, { opacity: 0, offset }, { opacity: 0, offset: 1 }, { opacity: 1, offset: 1 }, ], { duration: m.blinkoff + m.blinkon, iterations: Infinity, delay: m.blinkwait || 0, }, ); } }; const repositionCursor = (newCursor: [number, number]): void => { if (newCursor) cursorPosition = newCursor; const left = cursorPosition[1] * charWidth; const top = cursorPosition[0] * charHeight; cursorEl.style.transform = `translate(${left}px, ${top}px)`; }; const optionSet = { guifont: (newFont: string) => { const [newFontFamily, newFontSize] = newFont.trim().split(':h'); if (newFontFamily && newFontFamily !== '') { nvim.command(`VVset fontfamily=${newFontFamily.replace(/_/g, '\\ ')}`); if (newFontSize && newFontFamily !== '') { nvim.command(`VVset fontsize=${newFontSize}`); } } }, }; const recalculateHighlightTable = () => { (Object.keys(highlightTable) as unknown as number[]).forEach((id) => { if (id > 0) { const { foreground, background, special, reverse, standout, italic, bold, underline, undercurl, strikethrough, } = highlightTable[id].value || {}; const r = reverse || standout; const fg = getColor(foreground, highlightTable[0]?.calculated?.fgColor) as string; const bg = getColor(background, highlightTable[0]?.calculated?.bgColor) as string; const sp = getColor(special, highlightTable[0]?.calculated?.spColor) as string; highlightTable[id as unknown as number].calculated = { fgColor: r ? bg : fg, bgColor: r ? fg : bg, spColor: sp, hiItalic: showItalic && !!italic, hiBold: showBold && !!bold, hiUnderline: showUnderline && !!underline, hiUndercurl: showUndercurl && !!undercurl, hiStrikethrough: showStrikethrough && !!strikethrough, }; } }); }; /** * If char previous to the current cell is wider that char width, we need to draw that part * of it that overlaps the current cell when we redraw it. */ const overlapPrev = (i: number, j: number) => { if (chars[i] && chars[i][j - 1] && chars[i][j - 1].bitmap) { context.drawImage( chars[i][j - 1].bitmap as HTMLCanvasElement, charWidth * 2, 0, charWidth, charHeight, j * charWidth, i * charHeight, charWidth, charHeight, ); } }; /** * If char next to the cell is wider that char width, we need to draw that part * of it that overlaps the current cell when we redraw it. */ const overlapNext = (i: number, j: number) => { if (chars[i] && chars[i][j + 1] && chars[i][j + 1].bitmap) { context.drawImage( chars[i][j + 1].bitmap as HTMLCanvasElement, 0, 0, charWidth, charHeight, j * charWidth, i * charHeight, charWidth, charHeight, ); } }; /** Clean char from previous overlapping left and right symbols. */ const cleanOverlap = (i: number, j: number) => { if (chars[i] && chars[i][j]) { const { hlId } = chars[i][j]; if (hlId !== undefined) { const fillStyle = highlightTable[hlId]?.calculated?.bgColor; if (fillStyle) { context.fillStyle = fillStyle; context.fillRect(j * charWidth, i * charHeight, charWidth, charHeight); context.drawImage( chars[i][j].bitmap as HTMLCanvasElement, charWidth, 0, charWidth, charHeight, j * charWidth, i * charHeight, charWidth, charHeight, ); overlapPrev(i, j); overlapNext(i, j); } } } }; // https://github.com/neovim/neovim/blob/master/runtime/doc/ui.txt const redrawCmd: Partial = { set_title: () => { /* empty */ }, set_icon: () => { /* empty */ }, win_viewport: () => { /* empty */ }, mode_info_set: (props) => { modeInfoSet = props[0][1].reduce((r, modeInfo) => ({ ...r, [modeInfo.name]: modeInfo }), {}); }, option_set: (options) => { options.forEach(([option, value]) => { // @ts-expect-error TODO if (optionSet[option]) { // @ts-expect-error TODO optionSet[option](value); } else { // console.warn('Unknown option', option, value); // eslint-disable-line no-console } }); }, mode_change: (modes) => { mode = modes[modes.length - 1][0]; }, mouse_on: () => { /* empty */ }, mouse_off: () => { /* empty */ }, busy_start: () => { /* empty */ }, busy_stop: () => { /* empty */ }, suspend: () => { /* empty */ }, update_menu: () => { /* empty */ }, bell: () => { /* empty */ }, visual_bell: () => { /* empty */ }, hl_group_set: () => { /* empty */ }, flush: () => { redrawCursor(); }, grid_resize: ([[, newCols, newRows]]) => { const oldCols = cols; const oldRows = rows; cols = newCols; rows = newRows; // Add extra column on the right to fill it with adjacent color to have a nice right border if ((cols + 1) * charWidth > canvasEl.width || rows * charHeight > canvasEl.height) { const width = (cols + 1) * charWidth; const height = rows * charHeight; screenContainer.style.width = `${width}px`; screenContainer.style.height = `${height}px`; canvasEl.width = (cols + 1) * charWidth; canvasEl.height = rows * charHeight; context.fillStyle = highlightTable[0]?.calculated?.bgColor || DEFAULT_BG_COLOR; context.fillRect(0, 0, canvasEl.width, canvasEl.height); } // If we are not resizing the window, then we triggered resize from vim using `:set columns` or `:set lines`. // We need to send message to the main to resize the window. if (!isResizing) { if (oldCols !== cols) { const width = Math.ceil((cols * charWidth) / scale); transport.send('set-screen-width', width); } if (oldRows !== rows) { const height = Math.ceil((rows * charHeight) / scale); transport.send('set-screen-height', height); } } }, default_colors_set: (props) => { const [foreground, background, special] = props[props.length - 1]; const calculated = { bgColor: getColor(background, DEFAULT_BG_COLOR) as string, fgColor: getColor(foreground, DEFAULT_FG_COLOR) as string, spColor: getColor(special, DEFAULT_SP_COLOR), hiItalic: false, hiBold: false, hiUnderline: false, hiUndercurl: false, hiStrikethrough: false, }; if (!highlightTable[0] || !isEqual(highlightTable[0].calculated, calculated)) { highlightTable[0] = { calculated }; highlightTable[-1] = { calculated: { ...calculated, bgColor: getColor(foreground, DEFAULT_FG_COLOR) as string, fgColor: getColor(background, DEFAULT_BG_COLOR) as string, }, }; recalculateHighlightTable(); if (highlightTable[0]?.calculated?.bgColor) { document.body.style.background = highlightTable[0].calculated.bgColor; transport.send('set-background-color', highlightTable[0].calculated.bgColor); } redrawDefaultColors(); } }, hl_attr_define: (props) => { props.forEach(([id, value]) => { highlightTable[id] = { value, }; }); recalculateHighlightTable(); }, grid_line: (props) => { // eslint-disable-next-line for (const [, row, col, cells] of props) { let lineLength = 0; let currentHlId = 0; // eslint-disable-next-line for (const [_char, hlId, length = 1] of cells) { if (hlId !== undefined) { currentHlId = hlId; } if (length > 0) { printBackground(currentHlId, row, col + lineLength, length); lineLength += length; } } currentHlId = 0; lineLength = 0; // eslint-disable-next-line for (const [char, hlId, length = 1] of cells) { if (hlId !== undefined) { currentHlId = hlId; } for (let j = 0; j < length; j += 1) { printChar(row, col + lineLength + j, char, currentHlId); } lineLength += length; } cleanOverlap(row, col - 1); cleanOverlap(row, col + lineLength); overlapPrev(row, col); overlapNext(row, col + lineLength - 1); } }, grid_clear: () => { cursorPosition = [0, 0]; context.fillStyle = highlightTable[0]?.calculated?.bgColor || DEFAULT_BG_COLOR; context.fillRect(0, 0, canvasEl.width, canvasEl.height); chars = []; }, grid_destroy: () => { /* empty */ }, grid_cursor_goto: ([[_, ...newCursor]]) => { repositionCursor(newCursor); // Temporary workaround to fix cursor position in terminal mode. Nvim API does not send the very last cursor // position in terminal on redraw, but when you send any command to nvim, it redraws it correctly. Need to // investigate it and find a better permanent fix. Maybe this is a bug in nvim and then // TODO: file a ticket to nvim. nvim.getMode(); }, grid_scroll: ([[_grid, top, bottom, left, right, scrollCount]]) => { const x = left * charWidth; // region left let y; // region top let w = (right - left) * charWidth; // clipped part width const h = (bottom - top - Math.abs(scrollCount)) * charHeight; // clipped part height const X = x; // destination left let Y; // destination top if (right === cols) { // Add extra char if it is far right rect w += charWidth; } if (scrollCount > 0) { // scroll down y = (top + scrollCount) * charHeight; Y = top * charHeight; } else { // scroll up y = top * charHeight; Y = (top - scrollCount) * charHeight; } // Copy scrolled lines // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage context.drawImage(canvasEl, x, y, w, h, X, Y, w, h); for ( let i = scrollCount > 0 ? top : bottom - 1; scrollCount > 0 ? i <= bottom - scrollCount - 1 : i >= top - scrollCount; i += scrollCount > 0 ? 1 : -1 ) { for (let j = left; j <= right - 1; j += 1) { const sourceI = i + scrollCount; initChar(i, j); initChar(sourceI, j); // Swap char to scroll to destination [chars[i][j], chars[sourceI][j]] = [chars[sourceI][j], chars[i][j]]; } } for (let i = top; i <= bottom; i += 1) { cleanOverlap(i, left - 1); cleanOverlap(i, right); } }, }; const handleSet = { fontfamily: (newFontFamily: string) => { fontFamily = `${newFontFamily}, ${DEFAULT_FONT_FAMILY}`; }, fontsize: (newFontSize: string) => { fontSize = parseInt(newFontSize, 10); }, letterspacing: (newLetterSpacing: string) => { letterSpacing = parseInt(newLetterSpacing, 10); }, lineheight: (newLineHeight: string) => { lineHeight = parseFloat(newLineHeight); }, bold: (value: boolean) => { showBold = value; }, italic: (value: boolean) => { showItalic = value; }, underline: (value: boolean) => { showUnderline = value; }, undercurl: (value: boolean) => { showUndercurl = value; }, strikethrough: (value: boolean) => { showStrikethrough = value; }, }; const redraw = (args: UiEventsArgs) => { try { args.forEach(([cmd, ...props]) => { const command = redrawCmd[cmd]; if (command) { // console.log('hey', cmd, props); // @ts-expect-error TODO: find the way to type it without errors command(props); } else { console.warn('Unknown redraw command', cmd, props); // eslint-disable-line no-console } }); } catch (e) { // eslint-disable-next-line console.error(e); } }; const setScale = () => { scale = isRetina() ? RETINA_SCALE : 1; screenContainer.style.transform = `scale(${1 / scale})`; screenContainer.style.width = `${scale * 100}%`; screenContainer.style.height = `${scale * 100}%`; // Detect when you drag between retina/non-retina displays window.matchMedia('screen and (min-resolution: 2dppx)').addListener(() => { setScale(); measureCharSize(); setResizing(); nvim.uiTryResize(cols, rows); }); }; /** * Return grid [col, row] coordinates by pixel coordinates. */ const screenCoords = (width: number, height: number): [number, number] => { return [Math.floor((width * scale) / charWidth), Math.floor((height * scale) / charHeight)]; }; const resize = (forceRedraw = false) => { const [newCols, newRows] = screenCoords(window.innerWidth, window.innerHeight); if (newCols !== cols || newRows !== rows || forceRedraw) { cols = newCols; rows = newRows; setResizing(); nvim.uiTryResize(cols, rows); } }; const uiAttach = () => { [cols, rows] = screenCoords(window.innerWidth, window.innerHeight); nvim.uiAttach(cols, rows, { ext_linegrid: true }); window.addEventListener('resize', () => resize()); }; const updateSettings = (newSettings: Settings, isInitial = false) => { let requireRedraw = isInitial; let requireRecalculateHighlight = false; const requireRedrawProps = [ 'fontfamily', 'fontsize', 'letterspacing', 'lineheight', 'bold', 'italic', 'underline', 'undercurl', 'strikethrough', ]; const requireRecalculateHighlightProps = [ 'bold', 'italic', 'underline', 'undercurl', 'strikethrough', ]; Object.keys(newSettings).forEach((key) => { // @ts-expect-error TODO if (handleSet[key]) { requireRedraw = requireRedraw || requireRedrawProps.includes(key); requireRecalculateHighlight = requireRecalculateHighlight || requireRecalculateHighlightProps.includes(key); // @ts-expect-error TODO handleSet[key](newSettings[key]); } }); if (requireRecalculateHighlight && !isInitial) { recalculateHighlightTable(); } if (requireRedraw) { measureCharSize(); charsCache.clear(); if (!isInitial) { resize(true); } } }; initScreen(); initCursor(); setScale(); nvim.on('redraw', redraw); transport.on('updateSettings', (s) => updateSettings(s)); updateSettings(settings, true); transport.on('force-resize', () => { resize(); }); uiAttach(); return { screenCoords, getCursorElement, }; }; export default screen; ================================================ FILE: packages/browser-renderer/src/transport/__tests__/ipc.test.ts ================================================ import { EventEmitter } from 'events'; import { ipcRenderer } from 'src/preloaded/electron'; import type { PreloadedIpcRenderer } from 'src/preloaded/electron'; import IpcRendererTransport from 'src/transport/ipc'; jest.mock('src/preloaded/electron', () => ({ ipcRenderer: { on: jest.fn(), send: jest.fn(), }, })); describe('main transport', () => { let transport: IpcRendererTransport; let ipcRendererMock: NodeJS.EventEmitter; const send = jest.fn(); beforeEach(() => { ipcRendererMock = Object.assign(new EventEmitter(), { send, }); transport = new IpcRendererTransport((ipcRendererMock as unknown) as PreloadedIpcRenderer); }); describe('on', () => { const listener = jest.fn(); test('calls listener', () => { transport.on('test-event', listener); ipcRendererMock.emit('test-event', 'arg1', 'arg2'); expect(listener).toHaveBeenCalledWith('arg1', 'arg2'); }); test('does not call listener twice listener', () => { const anotherListener = jest.fn(); transport.on('test-event', listener); transport.on('test-event', anotherListener); ipcRendererMock.emit('test-event', new Event('test-event'), 'arg1', 'arg2'); expect(listener).toHaveBeenCalledTimes(1); expect(anotherListener).toHaveBeenCalledTimes(1); }); test('listener with no args', () => { transport.on('test-event', listener); ipcRendererMock.emit('test-event'); expect(listener).toHaveBeenCalledWith(); }); test('use preloaded ipcRenderer if it is not passed', () => { transport = new IpcRendererTransport(); transport.on('test-event', listener); expect(ipcRenderer.on).toHaveBeenCalledWith('test-event', expect.any(Function)); }); }); describe('once', () => { const listener = jest.fn(); test('calls listener once', () => { transport.once('test-event', listener); ipcRendererMock.emit('test-event', new Event('test-event'), 'arg1', 'arg2'); ipcRendererMock.emit('test-event', new Event('test-event'), 'arg1', 'arg2'); expect(listener).toHaveBeenCalledTimes(1); }); }); describe('removeListener', () => { const listener = jest.fn(); test('does not call listener after off', () => { transport.on('test-event', listener); transport.removeListener('test-event', listener); ipcRendererMock.emit('test-event', new Event('test-event'), 'arg1', 'arg2'); expect(listener).not.toHaveBeenCalled(); }); test('other subscribed events work', () => { const anotherListener = jest.fn(); transport.on('test-event', listener); transport.on('test-event', anotherListener); transport.removeListener('test-event', anotherListener); ipcRendererMock.emit('test-event', new Event('test-event'), 'arg1', 'arg2'); expect(listener).toHaveBeenCalled(); }); test('unsubscribes from ipc event if there are not subscriptions left', () => { const addListenerSpy = jest.spyOn(ipcRendererMock, 'on'); const removeListenerSpy = jest.spyOn(ipcRendererMock, 'removeListener'); transport.on('test-event', listener); transport.off('test-event', listener); expect(removeListenerSpy).toHaveBeenCalledWith('test-event', addListenerSpy.mock.calls[0][1]); }); }); describe('send', () => { test('pass args to win.webContents', () => { transport.send('test-event', 'arg1', 'arg2'); expect(send).toHaveBeenCalledWith('test-event', 'arg1', 'arg2'); }); test('with no args', () => { transport.send('test-event'); expect(send).toHaveBeenCalledWith('test-event'); }); }); }); ================================================ FILE: packages/browser-renderer/src/transport/__tests__/websocket.test.ts ================================================ import WebSocketTransport from 'src/transport/websocket'; describe('websocket transport', () => { const OriginalWebSocket = WebSocket; const constructor = jest.fn(); const send = jest.fn(); let onmessage: (x: { data: string }) => void; const listener = jest.fn(); class MockWebSocket { constructor(...args: any[]) { constructor(...args); } // eslint-disable-next-line class-methods-use-this send(...args: any[]) { send(...args); } // eslint-disable-next-line class-methods-use-this set onmessage(value: (x: { data: string }) => void) { onmessage = value; } } let transport: WebSocketTransport; beforeEach(() => { // @ts-expect-error Mocking WebSocket global.WebSocket = MockWebSocket; transport = new WebSocketTransport(); }); afterEach(() => { global.WebSocket = OriginalWebSocket; }); test('connects to websocket', () => { expect(constructor).toHaveBeenCalledWith('ws://localhost'); }); test('send method sends channel and args to websocket', () => { transport.send('channel', 'arg1', 'arg2'); expect(send).toHaveBeenCalledWith('["channel","arg1","arg2"]'); }); test('sent message is JSON stringified', () => { transport.send('channel', { complex: { object: true } }); expect(send).toHaveBeenCalledWith('["channel",{"complex":{"object":true}}]'); }); test('receive message if you subscribe to chanel', () => { transport.on('channel', listener); onmessage({ data: JSON.stringify(['channel', ['arg1', 'arg2']]) }); expect(listener).toHaveBeenCalledWith(['arg1', 'arg2']); onmessage({ data: JSON.stringify(['channel', ['arg3']]) }); expect(listener).toHaveBeenCalledWith(['arg3']); }); test("don't receive messages for channels you did not subscribe", () => { transport.on('channel', listener); onmessage({ data: JSON.stringify(['other-channel', ['arg1', 'arg2']]) }); expect(listener).not.toHaveBeenCalled(); }); }); ================================================ FILE: packages/browser-renderer/src/transport/ipc.ts ================================================ import { EventEmitter } from 'events'; import memoize from 'lodash/memoize'; import { ipcRenderer } from 'src/preloaded/electron'; import type { PreloadedIpcRenderer } from 'src/preloaded/electron'; import type { Transport, Args } from '@vvim/nvim'; /** * Init transport between main and renderer via Electron ipcRenderer. */ class IpcRendererTransport extends EventEmitter implements Transport { private ipc: PreloadedIpcRenderer; constructor(ipc = ipcRenderer) { super(); this.ipc = ipc; this.on('newListener', (eventName: string) => { if ( !this.listenerCount(eventName) && !['newListener', 'removeListener'].includes(eventName) ) { this.ipc.on(eventName, this.handleEvent(eventName)); } }); this.on('removeListener', (eventName: string) => { if ( !this.listenerCount(eventName) && !['newListener', 'removeListener'].includes(eventName) ) { this.ipc.removeListener(eventName, this.handleEvent(eventName)); } }); } handleEvent = memoize((eventName: string) => (...args: Args): void => { this.emit(eventName, ...args); }); send(channel: string, ...params: Args): void { this.ipc.send(channel, ...params); } } export default IpcRendererTransport; ================================================ FILE: packages/browser-renderer/src/transport/transport.ts ================================================ import IpcRendererTransport from 'src/transport/ipc'; import WebSocketTransport from 'src/transport/websocket'; import isWeb from 'src/lib/isWeb'; const Transport = isWeb() ? WebSocketTransport : IpcRendererTransport; export default Transport; ================================================ FILE: packages/browser-renderer/src/transport/websocket.ts ================================================ import { EventEmitter } from 'events'; import type { Transport, Args } from '@vvim/nvim'; /** * Init transport between main and renderer via WebSocket. */ class WebSocketTransport extends EventEmitter implements Transport { socket: WebSocket; constructor() { super(); this.socket = new WebSocket(`ws://${window.location.host}`); this.socket.onmessage = ({ data }) => { const [channel, args] = JSON.parse(data); this.emit(channel, args); }; } send(channel: string, ...args: Args): void { this.socket.send(JSON.stringify([channel, ...args])); } } export default WebSocketTransport; ================================================ FILE: packages/browser-renderer/src/types.ts ================================================ type BooleanSetting = 0 | 1; export type Settings = { fullscreen: BooleanSetting; simplefullscreen: BooleanSetting; bold: BooleanSetting; italic: BooleanSetting; underline: BooleanSetting; undercurl: BooleanSetting; strikethrough: BooleanSetting; fontfamily: string; fontsize: string; // TODO: number lineheight: string; // TODO: number letterspacing: string; // TODO: number reloadchanged: BooleanSetting; quitoncloselastwindow: BooleanSetting; autoupdateinterval: string; // TODO: number openInProject: BooleanSetting; }; ================================================ FILE: packages/browser-renderer/tsconfig.declaration.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "emitDeclarationOnly": true, "declarationMap": true, "noEmit": false, "outDir": "dist" }, "include": ["src/index.ts", "@types"] } ================================================ FILE: packages/browser-renderer/tsconfig.json ================================================ { "extends": "../../tsconfig", "compilerOptions": { "baseUrl": "." }, "include": ["src", "@types"] } ================================================ FILE: packages/electron/@types/html2plaintext.d.ts ================================================ declare module 'html2plaintext' { const html2plaintext: (x: string) => string; export default html2plaintext; } ================================================ FILE: packages/electron/README.md ================================================ # VV VV is a Neovim client for macOS. A pure, fast, minimalistic Vim experience with good macOS integration. Optimized for speed and nice font rendering. Please check main readme file for details: [README.md](../../README.md). ================================================ FILE: packages/electron/babel.config.json ================================================ { "extends": "../../babel.config.json" } ================================================ FILE: packages/electron/bin/openInProject.vim ================================================ " Opens file respecting switchbuf setting. function! VVopenInProject(filename, ...) " Take switch override from second parameter or from VV settings. let l:switchbuf_override = get(a:, 1, VVsettingValue('openInProject')) silent call VVopenInProjectLoud(a:filename, l:switchbuf_override) endfunction function! VVopenInProjectLoud(fileName, switchbuf_override) " Temporary override switchbuf if we have custom openInProject setting. if type(a:switchbuf_override) == v:t_string && a:switchbuf_override != '0' && a:switchbuf_override != '1' let l:original_switchbuf = &switchbuf let &switchbuf = a:switchbuf_override endif " Create temporary quickfix list with file we want to open if (!exists('g:vvOpenInProjectQfId') || getqflist({ 'id': 0 }).id != g:vvOpenInProjectQfId) call setqflist([], ' ', { 'title': 'VV Temporary Quickfix' }) let g:vvOpenInProjectQfId = getqflist({ 'id': 0 }).id end " Add file to list and open it. It will be opened according to current switchbuf " setting. call setqflist([], 'r', { 'id': g:vvOpenInProjectQfId, 'items': [{ 'filename': a:fileName }] }) cc! 1 " Switch to previous quickfix list if there are other lists. if getqflist({'nr' : 0 }).nr > 1 colder end " Rollback to original switchbuf option if needed. if exists('l:original_switchbuf') let &switchbuf = l:original_switchbuf endif endfunction function! VVprojectRoot() return getcwd() endfunction ================================================ FILE: packages/electron/bin/reloadChanged.vim ================================================ " TODO: Remove on the next major version " Iterate on buffers and reload them from disk. No questions asked. " Do it in temporary tab to keep the same windows layout. function! VVrefresh(...) -tabnew for bufnr in a:000 execute "buffer" bufnr execute "e!" endfor tabclose! endfunction function! VVenableReloadChanged(enabled) if a:enabled augroup ReloadChanged autocmd! autocmd FileChangedShell * call rpcnotify(get(g:, "vv_channel", 1), "vv:file_changed", { "name": expand(""), "bufnr": expand("") }) autocmd CursorHold * checktime augroup END else autocmd! ReloadChanged * endif endfunction ================================================ FILE: packages/electron/bin/vv ================================================ #!/bin/sh if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then cat << END VV - NeoVim GUI Client Usage: vv [options] [file ...] Options: --debug Debug mode. Keep process attached to terminal and output errors. All other options will be passed to nvim. You can check available options by running: nvim --help END else SOURCE="${BASH_SOURCE[0]}" while [ -h "$SOURCE" ]; do DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" SOURCE="$(readlink "$SOURCE")" [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" done DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" BIN="$DIR/../../MacOS/VV" if [[ "${@#--debug}" = "$@" ]]; then exec "$BIN" "$@" &>/dev/null & disown else exec "$BIN" "${@#--debug}" fi fi ================================================ FILE: packages/electron/bin/vv.vim ================================================ let g:vv = 1 let s:dir = expand(':p:h') execute 'source ' . fnameescape(s:dir . '/vvset.vim') execute 'source ' . fnameescape(s:dir . '/reloadChanged.vim') execute 'source ' . fnameescape(s:dir . '/openInProject.vim') set termguicolors autocmd VimEnter * call rpcnotify(get(g:, 'vv_channel', 1), "vv:vim_enter") " Send unsaved buffers to client function! VVunsavedBuffers() let l:buffers = getbufinfo() call filter(l:buffers, "v:val['changed'] == 1") let l:buffers = map(l:buffers , "{ 'name': v:val['name'] }" ) return l:buffers endfunction ================================================ FILE: packages/electron/bin/vvset.vim ================================================ let g:vv_settings_synonims = { \ 'fu': 'fullscreen', \ 'sfu': 'simplefullscreen', \ 'width': 'windowwidth', \ 'height': 'windowheight', \ 'top': 'windowtop', \ 'left': 'windowleft', \ 'openinproject': 'openInProject' \} let g:vv_default_settings = { \ 'fullscreen': 0, \ 'simplefullscreen': 1, \ 'bold': 1, \ 'italic': 1, \ 'underline': 1, \ 'undercurl': 1, \ 'strikethrough': 1, \ 'fontfamily': 'monospace', \ 'fontsize': 12, \ 'lineheight': 1.25, \ 'letterspacing': 0, \ 'reloadchanged': 0, \ 'windowwidth': v:null, \ 'windowheight': v:null, \ 'windowleft': v:null, \ 'windowtop': v:null, \ 'quitoncloselastwindow': 0, \ 'autoupdateinterval': 1440, \ 'openInProject': 1 \} let g:vv_settings = deepcopy(g:vv_default_settings) " Custom VVset command, mimic default set command (:help set) with " settings specified in g:vv_default_settings function! VVset(...) for arg in a:000 call VVsetItem(arg) endfor endfunction function! VVsettingValue(name) let l:name = VVsettingName(a:name) if has_key(g:vv_settings, l:name) return g:vv_settings[l:name] else echoerr "Unknown option: ".a:name endif endfunction function! VVsettingName(name) if has_key(g:vv_settings_synonims, a:name) return g:vv_settings_synonims[a:name] else return a:name endif endfunction function! VVsetItem(name) if a:name == 'all' echo g:vv_settings return elseif a:name =~ '?' let l:name = VVsettingName(split(a:name, '?')[0]) echo VVsettingValue(l:name) return elseif a:name =~ '&' let l:name = VVsettingName(split(a:name, '&')[0]) if l:name == 'all' let g:vv_settings = deepcopy(g:vv_default_settings) call VVsettings() return elseif has_key(g:vv_default_settings, l:name) let l:value = g:vv_default_settings[l:name] else echoerr "Unknown option: ".l:name return endif elseif a:name =~ '+=' let l:split = split(a:name, '+=') let l:name = VVsettingName(l:split[0]) let l:value = VVsettingValue(l:name) + l:split[1] elseif a:name =~ '-=' let l:split = split(a:name, '-=') let l:name = VVsettingName(l:split[0]) let l:value = VVsettingValue(l:name) - l:split[1] elseif a:name =~ '\^=' let l:split = split(a:name, '\^=') let l:name = VVsettingName(l:split[0]) let l:value = VVsettingValue(l:name) * l:split[1] elseif a:name =~ '=' let l:split = split(a:name, '=') let l:name = l:split[0] let l:value = l:split[1] elseif a:name =~ ':' let l:split = split(a:name, ':') let l:name = l:split[0] let l:value = l:split[1] elseif a:name =~ '!' let l:name = VVsettingName(split(a:name, '!')[0]) if VVsettingValue(l:name) == 0 let l:value = 1 else let l:value = 0 endif elseif a:name =~ '^inv' let l:name = VVsettingName(strpart(a:name, 3)) if VVsettingValue(l:name) == 0 let l:value = 1 else let l:value = 0 endif elseif a:name =~ '^no' let l:name = strpart(a:name, 2) let l:value = 0 else let l:name = a:name let l:value = 1 endif let l:name = VVsettingName(l:name) if has_key(g:vv_settings, l:name) let g:vv_settings[l:name] = l:value call rpcnotify(get(g:, "vv_channel", 1), "vv:set", l:name, l:value) else echoerr "Unknown option: ".l:name endif endfunction function! VVsettings() for key in keys(g:vv_settings) call rpcnotify(get(g:, "vv_channel", 1), "vv:set", key, g:vv_settings[key]) endfor endfunction command! -nargs=* VVset :call VVset() command! -nargs=* VVse :call VVset() command! -nargs=0 VVsettings :call VVsettings() " Send all settings to client ================================================ FILE: packages/electron/config/electron-builder/build.js ================================================ require('dotenv').config(); const { join } = require('path'); const { readFileSync } = require('fs'); const fileAssociations = require('./fileAssociations.json'); const path = require.resolve('electron'); const data = readFileSync(join(path, '..', 'package.json'), { encoding: 'utf-8' }); const electronVersion = JSON.parse(data).version; const build = { appId: process.env.APPID || 'app.vvim.vv', productName: 'VV', extraMetadata: { name: 'VV', }, files: ['build/**/*'], extraResources: ['bin/**/*', 'src/main/preload.js'], electronVersion, directories: { buildResources: 'assets', }, fileAssociations: [ ...fileAssociations, { name: 'Document', role: 'Editor', ext: '*', icon: 'generic.icns', }, ], mac: { category: 'public.app-category.developer-tools', target: { target: 'dir', arch: 'universal', }, }, }; module.exports = build; ================================================ FILE: packages/electron/config/electron-builder/fileAssociations.json ================================================ [ { "name": "1C Enterprise", "role": "Editor", "icon": "generic.icns", "ext": ["bsl", "os"] }, { "name": "ABAP", "role": "Editor", "icon": "generic.icns", "ext": ["abap"] }, { "name": "ABNF", "role": "Editor", "icon": "generic.icns", "ext": ["abnf"] }, { "name": "AGS Script", "role": "Editor", "icon": "generic.icns", "ext": ["asc", "ash"] }, { "name": "AMPL", "role": "Editor", "icon": "generic.icns", "ext": ["ampl", "mod"] }, { "name": "ANTLR", "role": "Editor", "icon": "generic.icns", "ext": ["g4"] }, { "name": "API Blueprint", "role": "Editor", "icon": "generic.icns", "ext": ["apib"] }, { "name": "APL", "role": "Editor", "icon": "generic.icns", "ext": ["apl", "dyalog"] }, { "name": "ASN.1", "role": "Editor", "icon": "generic.icns", "ext": ["asn", "asn1"] }, { "name": "ASP", "role": "Editor", "icon": "generic.icns", "ext": ["asp", "asax", "ascx", "ashx", "asmx", "aspx", "axd"] }, { "name": "ATS", "role": "Editor", "icon": "generic.icns", "ext": ["dats", "hats", "sats"] }, { "name": "ActionScript", "role": "Editor", "icon": "generic.icns", "ext": ["as"] }, { "name": "Ada", "role": "Editor", "icon": "generic.icns", "ext": ["adb", "ada", "ads"] }, { "name": "Adobe Font Metrics", "role": "Editor", "icon": "generic.icns", "ext": ["afm"] }, { "name": "Agda", "role": "Editor", "icon": "generic.icns", "ext": ["agda"] }, { "name": "Alloy", "role": "Editor", "icon": "generic.icns", "ext": ["als"] }, { "name": "Altium Designer", "role": "Editor", "icon": "generic.icns", "ext": ["OutJob", "PcbDoc", "PrjPCB", "SchDoc"] }, { "name": "AngelScript", "role": "Editor", "icon": "generic.icns", "ext": ["as", "angelscript"] }, { "name": "ApacheConf", "role": "Editor", "icon": "generic.icns", "ext": ["apacheconf", "vhost"] }, { "name": "Apex", "role": "Editor", "icon": "generic.icns", "ext": ["cls"] }, { "name": "Apollo Guidance Computer", "role": "Editor", "icon": "generic.icns", "ext": ["agc"] }, { "name": "AppleScript", "role": "Editor", "icon": "generic.icns", "ext": ["applescript", "scpt"] }, { "name": "Arc", "role": "Editor", "icon": "generic.icns", "ext": ["arc"] }, { "name": "AsciiDoc", "role": "Editor", "icon": "generic.icns", "ext": ["asciidoc", "adoc", "asc"] }, { "name": "AspectJ", "role": "Editor", "icon": "generic.icns", "ext": ["aj"] }, { "name": "Assembly", "role": "Editor", "icon": "generic.icns", "ext": ["asm", "a51", "inc", "nasm"] }, { "name": "Asymptote", "role": "Editor", "icon": "generic.icns", "ext": ["asy"] }, { "name": "Augeas", "role": "Editor", "icon": "generic.icns", "ext": ["aug"] }, { "name": "AutoHotkey", "role": "Editor", "icon": "generic.icns", "ext": ["ahk", "ahkl"] }, { "name": "AutoIt", "role": "Editor", "icon": "generic.icns", "ext": ["au3"] }, { "name": "Awk", "role": "Editor", "icon": "generic.icns", "ext": ["awk", "auk", "gawk", "mawk", "nawk"] }, { "name": "Ballerina", "role": "Editor", "icon": "generic.icns", "ext": ["bal"] }, { "name": "Batchfile", "role": "Editor", "icon": "generic.icns", "ext": ["bat", "cmd"] }, { "name": "Befunge", "role": "Editor", "icon": "generic.icns", "ext": ["befunge"] }, { "name": "BibTeX", "role": "Editor", "icon": "generic.icns", "ext": ["bib"] }, { "name": "Bison", "role": "Editor", "icon": "generic.icns", "ext": ["bison"] }, { "name": "BitBake", "role": "Editor", "icon": "generic.icns", "ext": ["bb"] }, { "name": "Blade", "role": "Editor", "icon": "generic.icns", "ext": ["blade", "blade.php"] }, { "name": "BlitzBasic", "role": "Editor", "icon": "generic.icns", "ext": ["bb", "decls"] }, { "name": "BlitzMax", "role": "Editor", "icon": "generic.icns", "ext": ["bmx"] }, { "name": "Bluespec", "role": "Editor", "icon": "generic.icns", "ext": ["bsv"] }, { "name": "Boo", "role": "Editor", "icon": "generic.icns", "ext": ["boo"] }, { "name": "Brainfuck", "role": "Editor", "icon": "generic.icns", "ext": ["b", "bf"] }, { "name": "Brightscript", "role": "Editor", "icon": "generic.icns", "ext": ["brs"] }, { "name": "C", "role": "Editor", "icon": "generic.icns", "ext": ["c", "cats", "h", "idc"] }, { "name": "C#", "role": "Editor", "icon": "generic.icns", "ext": ["cs", "cake", "csx"] }, { "name": "C++", "role": "Editor", "icon": "generic.icns", "ext": [ "cpp", "c++", "cc", "cp", "cxx", "h", "h++", "hh", "hpp", "hxx", "inc", "inl", "ino", "ipp", "re", "tcc", "tpp" ] }, { "name": "C-ObjDump", "role": "Editor", "icon": "generic.icns", "ext": ["c-objdump"] }, { "name": "C2hs Haskell", "role": "Editor", "icon": "generic.icns", "ext": ["chs"] }, { "name": "CLIPS", "role": "Editor", "icon": "generic.icns", "ext": ["clp"] }, { "name": "CMake", "role": "Editor", "icon": "generic.icns", "ext": ["cmake", "cmake.in"] }, { "name": "COBOL", "role": "Editor", "icon": "generic.icns", "ext": ["cob", "cbl", "ccp", "cobol", "cpy"] }, { "name": "COLLADA", "role": "Editor", "icon": "generic.icns", "ext": ["dae"] }, { "name": "CSON", "role": "Editor", "icon": "generic.icns", "ext": ["cson"] }, { "name": "CSS", "role": "Editor", "icon": "generic.icns", "ext": ["css"] }, { "name": "CSV", "role": "Editor", "icon": "generic.icns", "ext": ["csv"] }, { "name": "CWeb", "role": "Editor", "icon": "generic.icns", "ext": ["w"] }, { "name": "Cabal Config", "role": "Editor", "icon": "generic.icns", "ext": ["cabal"] }, { "name": "Cap'n Proto", "role": "Editor", "icon": "generic.icns", "ext": ["capnp"] }, { "name": "CartoCSS", "role": "Editor", "icon": "generic.icns", "ext": ["mss"] }, { "name": "Ceylon", "role": "Editor", "icon": "generic.icns", "ext": ["ceylon"] }, { "name": "Chapel", "role": "Editor", "icon": "generic.icns", "ext": ["chpl"] }, { "name": "Charity", "role": "Editor", "icon": "generic.icns", "ext": ["ch"] }, { "name": "ChucK", "role": "Editor", "icon": "generic.icns", "ext": ["ck"] }, { "name": "Cirru", "role": "Editor", "icon": "generic.icns", "ext": ["cirru"] }, { "name": "Clarion", "role": "Editor", "icon": "generic.icns", "ext": ["clw"] }, { "name": "Clean", "role": "Editor", "icon": "generic.icns", "ext": ["icl", "dcl"] }, { "name": "Click", "role": "Editor", "icon": "generic.icns", "ext": ["click"] }, { "name": "Clojure", "role": "Editor", "icon": "generic.icns", "ext": ["clj", "boot", "cl2", "cljc", "cljs", "cljs.hl", "cljscm", "cljx", "hic"] }, { "name": "Closure Templates", "role": "Editor", "icon": "generic.icns", "ext": ["soy"] }, { "name": "CoNLL-U", "role": "Editor", "icon": "generic.icns", "ext": ["conllu", "conll"] }, { "name": "CoffeeScript", "role": "Editor", "icon": "generic.icns", "ext": ["coffee", "_coffee", "cake", "cjsx", "iced"] }, { "name": "ColdFusion", "role": "Editor", "icon": "generic.icns", "ext": ["cfm", "cfml"] }, { "name": "ColdFusion CFC", "role": "Editor", "icon": "generic.icns", "ext": ["cfc"] }, { "name": "Common Lisp", "role": "Editor", "icon": "generic.icns", "ext": ["lisp", "asd", "cl", "l", "lsp", "ny", "podsl", "sexp"] }, { "name": "Common Workflow Language", "role": "Editor", "icon": "generic.icns", "ext": ["cwl"] }, { "name": "Component Pascal", "role": "Editor", "icon": "generic.icns", "ext": ["cp", "cps"] }, { "name": "Cool", "role": "Editor", "icon": "generic.icns", "ext": ["cl"] }, { "name": "Coq", "role": "Editor", "icon": "generic.icns", "ext": ["coq", "v"] }, { "name": "Cpp-ObjDump", "role": "Editor", "icon": "generic.icns", "ext": ["cppobjdump", "c++-objdump", "c++objdump", "cpp-objdump", "cxx-objdump"] }, { "name": "Creole", "role": "Editor", "icon": "generic.icns", "ext": ["creole"] }, { "name": "Crystal", "role": "Editor", "icon": "generic.icns", "ext": ["cr"] }, { "name": "Csound", "role": "Editor", "icon": "generic.icns", "ext": ["orc", "udo"] }, { "name": "Csound Document", "role": "Editor", "icon": "generic.icns", "ext": ["csd"] }, { "name": "Csound Score", "role": "Editor", "icon": "generic.icns", "ext": ["sco"] }, { "name": "Cuda", "role": "Editor", "icon": "generic.icns", "ext": ["cu", "cuh"] }, { "name": "Cycript", "role": "Editor", "icon": "generic.icns", "ext": ["cy"] }, { "name": "Cython", "role": "Editor", "icon": "generic.icns", "ext": ["pyx", "pxd", "pxi"] }, { "name": "D", "role": "Editor", "icon": "generic.icns", "ext": ["d", "di"] }, { "name": "D-ObjDump", "role": "Editor", "icon": "generic.icns", "ext": ["d-objdump"] }, { "name": "DIGITAL Command Language", "role": "Editor", "icon": "generic.icns", "ext": ["com"] }, { "name": "DM", "role": "Editor", "icon": "generic.icns", "ext": ["dm"] }, { "name": "DNS Zone", "role": "Editor", "icon": "generic.icns", "ext": ["zone", "arpa"] }, { "name": "DTrace", "role": "Editor", "icon": "generic.icns", "ext": ["d"] }, { "name": "Darcs Patch", "role": "Editor", "icon": "generic.icns", "ext": ["darcspatch", "dpatch"] }, { "name": "Dart", "role": "Editor", "icon": "generic.icns", "ext": ["dart"] }, { "name": "DataWeave", "role": "Editor", "icon": "generic.icns", "ext": ["dwl"] }, { "name": "Dhall", "role": "Editor", "icon": "generic.icns", "ext": ["dhall"] }, { "name": "Diff", "role": "Editor", "icon": "generic.icns", "ext": ["diff", "patch"] }, { "name": "Dockerfile", "role": "Editor", "icon": "generic.icns", "ext": ["dockerfile"] }, { "name": "Dogescript", "role": "Editor", "icon": "generic.icns", "ext": ["djs"] }, { "name": "Dylan", "role": "Editor", "icon": "generic.icns", "ext": ["dylan", "dyl", "intr", "lid"] }, { "name": "E", "role": "Editor", "icon": "generic.icns", "ext": ["E"] }, { "name": "EBNF", "role": "Editor", "icon": "generic.icns", "ext": ["ebnf"] }, { "name": "ECL", "role": "Editor", "icon": "generic.icns", "ext": ["ecl", "eclxml"] }, { "name": "ECLiPSe", "role": "Editor", "icon": "generic.icns", "ext": ["ecl"] }, { "name": "EJS", "role": "Editor", "icon": "generic.icns", "ext": ["ejs"] }, { "name": "EML", "role": "Editor", "icon": "generic.icns", "ext": ["eml", "mbox"] }, { "name": "EQ", "role": "Editor", "icon": "generic.icns", "ext": ["eq"] }, { "name": "Eagle", "role": "Editor", "icon": "generic.icns", "ext": ["sch", "brd"] }, { "name": "Easybuild", "role": "Editor", "icon": "generic.icns", "ext": ["eb"] }, { "name": "Ecere Projects", "role": "Editor", "icon": "generic.icns", "ext": ["epj"] }, { "name": "Edje Data Collection", "role": "Editor", "icon": "generic.icns", "ext": ["edc"] }, { "name": "Eiffel", "role": "Editor", "icon": "generic.icns", "ext": ["e"] }, { "name": "Elixir", "role": "Editor", "icon": "generic.icns", "ext": ["ex", "exs"] }, { "name": "Elm", "role": "Editor", "icon": "generic.icns", "ext": ["elm"] }, { "name": "Emacs Lisp", "role": "Editor", "icon": "generic.icns", "ext": ["el", "emacs", "emacs.desktop"] }, { "name": "EmberScript", "role": "Editor", "icon": "generic.icns", "ext": ["em", "emberscript"] }, { "name": "Erlang", "role": "Editor", "icon": "generic.icns", "ext": ["erl", "app.src", "es", "escript", "hrl", "xrl", "yrl"] }, { "name": "F#", "role": "Editor", "icon": "generic.icns", "ext": ["fs", "fsi", "fsx"] }, { "name": "F*", "role": "Editor", "icon": "generic.icns", "ext": ["fst"] }, { "name": "FIGlet Font", "role": "Editor", "icon": "generic.icns", "ext": ["flf"] }, { "name": "FLUX", "role": "Editor", "icon": "generic.icns", "ext": ["fx", "flux"] }, { "name": "Factor", "role": "Editor", "icon": "generic.icns", "ext": ["factor"] }, { "name": "Fancy", "role": "Editor", "icon": "generic.icns", "ext": ["fy", "fancypack"] }, { "name": "Fantom", "role": "Editor", "icon": "generic.icns", "ext": ["fan"] }, { "name": "Filebench WML", "role": "Editor", "icon": "generic.icns", "ext": ["f"] }, { "name": "Filterscript", "role": "Editor", "icon": "generic.icns", "ext": ["fs"] }, { "name": "Formatted", "role": "Editor", "icon": "generic.icns", "ext": ["for", "eam.fs"] }, { "name": "Forth", "role": "Editor", "icon": "generic.icns", "ext": ["fth", "4th", "f", "for", "forth", "fr", "frt", "fs"] }, { "name": "Fortran", "role": "Editor", "icon": "generic.icns", "ext": ["f90", "f", "f03", "f08", "f77", "f95", "for", "fpp"] }, { "name": "FreeMarker", "role": "Editor", "icon": "generic.icns", "ext": ["ftl"] }, { "name": "Frege", "role": "Editor", "icon": "generic.icns", "ext": ["fr"] }, { "name": "G-code", "role": "Editor", "icon": "generic.icns", "ext": ["g", "cnc", "gco", "gcode"] }, { "name": "GAML", "role": "Editor", "icon": "generic.icns", "ext": ["gaml"] }, { "name": "GAMS", "role": "Editor", "icon": "generic.icns", "ext": ["gms"] }, { "name": "GAP", "role": "Editor", "icon": "generic.icns", "ext": ["g", "gap", "gd", "gi", "tst"] }, { "name": "GCC Machine Description", "role": "Editor", "icon": "generic.icns", "ext": ["md"] }, { "name": "GDB", "role": "Editor", "icon": "generic.icns", "ext": ["gdb", "gdbinit"] }, { "name": "GDScript", "role": "Editor", "icon": "generic.icns", "ext": ["gd"] }, { "name": "GLSL", "role": "Editor", "icon": "generic.icns", "ext": [ "glsl", "fp", "frag", "frg", "fs", "fsh", "fshader", "geo", "geom", "glslv", "gshader", "shader", "tesc", "tese", "vert", "vrx", "vsh", "vshader" ] }, { "name": "GN", "role": "Editor", "icon": "generic.icns", "ext": ["gn", "gni"] }, { "name": "Game Maker Language", "role": "Editor", "icon": "generic.icns", "ext": ["gml"] }, { "name": "Genie", "role": "Editor", "icon": "generic.icns", "ext": ["gs"] }, { "name": "Genshi", "role": "Editor", "icon": "generic.icns", "ext": ["kid"] }, { "name": "Gentoo Ebuild", "role": "Editor", "icon": "generic.icns", "ext": ["ebuild"] }, { "name": "Gentoo Eclass", "role": "Editor", "icon": "generic.icns", "ext": ["eclass"] }, { "name": "Gerber Image", "role": "Editor", "icon": "generic.icns", "ext": [ "gbr", "gbl", "gbo", "gbp", "gbs", "gko", "gml", "gpb", "gpt", "gtl", "gto", "gtp", "gts" ] }, { "name": "Gettext Catalog", "role": "Editor", "icon": "generic.icns", "ext": ["po", "pot"] }, { "name": "Gherkin", "role": "Editor", "icon": "generic.icns", "ext": ["feature"] }, { "name": "Git Config", "role": "Editor", "icon": "generic.icns", "ext": ["gitconfig"] }, { "name": "Glyph", "role": "Editor", "icon": "generic.icns", "ext": ["glf"] }, { "name": "Glyph Bitmap Distribution Format", "role": "Editor", "icon": "generic.icns", "ext": ["bdf"] }, { "name": "Gnuplot", "role": "Editor", "icon": "generic.icns", "ext": ["gp", "gnu", "gnuplot", "plot", "plt"] }, { "name": "Go", "role": "Editor", "icon": "generic.icns", "ext": ["go"] }, { "name": "Golo", "role": "Editor", "icon": "generic.icns", "ext": ["golo"] }, { "name": "Gosu", "role": "Editor", "icon": "generic.icns", "ext": ["gs", "gst", "gsx", "vark"] }, { "name": "Grace", "role": "Editor", "icon": "generic.icns", "ext": ["grace"] }, { "name": "Gradle", "role": "Editor", "icon": "generic.icns", "ext": ["gradle"] }, { "name": "Grammatical Framework", "role": "Editor", "icon": "generic.icns", "ext": ["gf"] }, { "name": "Graph Modeling Language", "role": "Editor", "icon": "generic.icns", "ext": ["gml"] }, { "name": "GraphQL", "role": "Editor", "icon": "generic.icns", "ext": ["graphql", "gql", "graphqls"] }, { "name": "Graphviz (DOT)", "role": "Editor", "icon": "generic.icns", "ext": ["dot", "gv"] }, { "name": "Groovy", "role": "Editor", "icon": "generic.icns", "ext": ["groovy", "grt", "gtpl", "gvy"] }, { "name": "Groovy Server Pages", "role": "Editor", "icon": "generic.icns", "ext": ["gsp"] }, { "name": "HAProxy", "role": "Editor", "icon": "generic.icns", "ext": ["cfg"] }, { "name": "HCL", "role": "Editor", "icon": "generic.icns", "ext": ["hcl", "tf", "tfvars", "workflow"] }, { "name": "HLSL", "role": "Editor", "icon": "generic.icns", "ext": ["hlsl", "cginc", "fx", "fxh", "hlsli"] }, { "name": "HTML", "role": "Editor", "icon": "generic.icns", "ext": ["html", "htm", "html.hl", "inc", "st", "xht", "xhtml"] }, { "name": "HTML+Django", "role": "Editor", "icon": "generic.icns", "ext": ["jinja", "jinja2", "mustache", "njk"] }, { "name": "HTML+ECR", "role": "Editor", "icon": "generic.icns", "ext": ["ecr"] }, { "name": "HTML+EEX", "role": "Editor", "icon": "generic.icns", "ext": ["eex"] }, { "name": "HTML+ERB", "role": "Editor", "icon": "generic.icns", "ext": ["erb", "erb.deface"] }, { "name": "HTML+PHP", "role": "Editor", "icon": "generic.icns", "ext": ["phtml"] }, { "name": "HTML+Razor", "role": "Editor", "icon": "generic.icns", "ext": ["cshtml", "razor"] }, { "name": "HTTP", "role": "Editor", "icon": "generic.icns", "ext": ["http"] }, { "name": "HXML", "role": "Editor", "icon": "generic.icns", "ext": ["hxml"] }, { "name": "Hack", "role": "Editor", "icon": "generic.icns", "ext": ["hack", "hh", "php"] }, { "name": "Haml", "role": "Editor", "icon": "generic.icns", "ext": ["haml", "haml.deface"] }, { "name": "Handlebars", "role": "Editor", "icon": "generic.icns", "ext": ["handlebars", "hbs"] }, { "name": "Harbour", "role": "Editor", "icon": "generic.icns", "ext": ["hb"] }, { "name": "Haskell", "role": "Editor", "icon": "generic.icns", "ext": ["hs", "hs-boot", "hsc"] }, { "name": "Haxe", "role": "Editor", "icon": "generic.icns", "ext": ["hx", "hxsl"] }, { "name": "HiveQL", "role": "Editor", "icon": "generic.icns", "ext": ["q"] }, { "name": "HolyC", "role": "Editor", "icon": "generic.icns", "ext": ["hc"] }, { "name": "Hy", "role": "Editor", "icon": "generic.icns", "ext": ["hy"] }, { "name": "HyPhy", "role": "Editor", "icon": "generic.icns", "ext": ["bf"] }, { "name": "IDL", "role": "Editor", "icon": "generic.icns", "ext": ["pro", "dlm"] }, { "name": "IGOR Pro", "role": "Editor", "icon": "generic.icns", "ext": ["ipf"] }, { "name": "INI", "role": "Editor", "icon": "generic.icns", "ext": ["ini", "cfg", "lektorproject", "prefs", "pro", "properties"] }, { "name": "IRC log", "role": "Editor", "icon": "generic.icns", "ext": ["irclog", "weechatlog"] }, { "name": "Idris", "role": "Editor", "icon": "generic.icns", "ext": ["idr", "lidr"] }, { "name": "Ignore List", "role": "Editor", "icon": "generic.icns", "ext": ["gitignore"] }, { "name": "Inform 7", "role": "Editor", "icon": "generic.icns", "ext": ["ni", "i7x"] }, { "name": "Inno Setup", "role": "Editor", "icon": "generic.icns", "ext": ["iss"] }, { "name": "Io", "role": "Editor", "icon": "generic.icns", "ext": ["io"] }, { "name": "Ioke", "role": "Editor", "icon": "generic.icns", "ext": ["ik"] }, { "name": "Isabelle", "role": "Editor", "icon": "generic.icns", "ext": ["thy"] }, { "name": "J", "role": "Editor", "icon": "generic.icns", "ext": ["ijs"] }, { "name": "JFlex", "role": "Editor", "icon": "generic.icns", "ext": ["flex", "jflex"] }, { "name": "JSON", "role": "Editor", "icon": "generic.icns", "ext": [ "json", "avsc", "geojson", "gltf", "har", "ice", "JSON-tmLanguage", "jsonl", "mcmeta", "tfstate", "tfstate.backup", "topojson", "webapp", "webmanifest", "yy", "yyp" ] }, { "name": "JSON with Comments", "role": "Editor", "icon": "generic.icns", "ext": [ "sublime-build", "sublime-commands", "sublime-completions", "sublime-keymap", "sublime-macro", "sublime-menu", "sublime-mousemap", "sublime-project", "sublime-settings", "sublime-theme", "sublime-workspace", "sublime_metrics", "sublime_session" ] }, { "name": "JSON5", "role": "Editor", "icon": "generic.icns", "ext": ["json5"] }, { "name": "JSONLD", "role": "Editor", "icon": "generic.icns", "ext": ["jsonld"] }, { "name": "JSONiq", "role": "Editor", "icon": "generic.icns", "ext": ["jq"] }, { "name": "JSX", "role": "Editor", "icon": "generic.icns", "ext": ["jsx"] }, { "name": "Jasmin", "role": "Editor", "icon": "generic.icns", "ext": ["j"] }, { "name": "Java", "role": "Editor", "icon": "generic.icns", "ext": ["java"] }, { "name": "Java Properties", "role": "Editor", "icon": "generic.icns", "ext": ["properties"] }, { "name": "Java Server Pages", "role": "Editor", "icon": "generic.icns", "ext": ["jsp"] }, { "name": "JavaScript", "role": "Editor", "icon": "generic.icns", "ext": [ "js", "_js", "bones", "es", "es6", "frag", "gs", "jake", "jsb", "jscad", "jsfl", "jsm", "jss", "mjs", "njs", "pac", "sjs", "ssjs", "xsjs", "xsjslib" ] }, { "name": "JavaScript+ERB", "role": "Editor", "icon": "generic.icns", "ext": ["js.erb"] }, { "name": "Jison", "role": "Editor", "icon": "generic.icns", "ext": ["jison"] }, { "name": "Jison Lex", "role": "Editor", "icon": "generic.icns", "ext": ["jisonlex"] }, { "name": "Jolie", "role": "Editor", "icon": "generic.icns", "ext": ["ol", "iol"] }, { "name": "Jsonnet", "role": "Editor", "icon": "generic.icns", "ext": ["jsonnet", "libsonnet"] }, { "name": "Julia", "role": "Editor", "icon": "generic.icns", "ext": ["jl"] }, { "name": "Jupyter Notebook", "role": "Editor", "icon": "generic.icns", "ext": ["ipynb"] }, { "name": "KRL", "role": "Editor", "icon": "generic.icns", "ext": ["krl"] }, { "name": "KiCad Layout", "role": "Editor", "icon": "generic.icns", "ext": ["kicad_pcb", "kicad_mod", "kicad_wks"] }, { "name": "KiCad Legacy Layout", "role": "Editor", "icon": "generic.icns", "ext": ["brd"] }, { "name": "KiCad Schematic", "role": "Editor", "icon": "generic.icns", "ext": ["sch"] }, { "name": "Kit", "role": "Editor", "icon": "generic.icns", "ext": ["kit"] }, { "name": "Kotlin", "role": "Editor", "icon": "generic.icns", "ext": ["kt", "ktm", "kts"] }, { "name": "LFE", "role": "Editor", "icon": "generic.icns", "ext": ["lfe"] }, { "name": "LLVM", "role": "Editor", "icon": "generic.icns", "ext": ["ll"] }, { "name": "LOLCODE", "role": "Editor", "icon": "generic.icns", "ext": ["lol"] }, { "name": "LSL", "role": "Editor", "icon": "generic.icns", "ext": ["lsl", "lslp"] }, { "name": "LTspice Symbol", "role": "Editor", "icon": "generic.icns", "ext": ["asy"] }, { "name": "LabVIEW", "role": "Editor", "icon": "generic.icns", "ext": ["lvproj"] }, { "name": "Lasso", "role": "Editor", "icon": "generic.icns", "ext": ["lasso", "las", "lasso8", "lasso9"] }, { "name": "Latte", "role": "Editor", "icon": "generic.icns", "ext": ["latte"] }, { "name": "Lean", "role": "Editor", "icon": "generic.icns", "ext": ["lean", "hlean"] }, { "name": "Less", "role": "Editor", "icon": "generic.icns", "ext": ["less"] }, { "name": "Lex", "role": "Editor", "icon": "generic.icns", "ext": ["l", "lex"] }, { "name": "LilyPond", "role": "Editor", "icon": "generic.icns", "ext": ["ly", "ily"] }, { "name": "Limbo", "role": "Editor", "icon": "generic.icns", "ext": ["b", "m"] }, { "name": "Linker Script", "role": "Editor", "icon": "generic.icns", "ext": ["ld", "lds", "x"] }, { "name": "Linux Kernel Module", "role": "Editor", "icon": "generic.icns", "ext": ["mod"] }, { "name": "Liquid", "role": "Editor", "icon": "generic.icns", "ext": ["liquid"] }, { "name": "Literate Agda", "role": "Editor", "icon": "generic.icns", "ext": ["lagda"] }, { "name": "Literate CoffeeScript", "role": "Editor", "icon": "generic.icns", "ext": ["litcoffee"] }, { "name": "Literate Haskell", "role": "Editor", "icon": "generic.icns", "ext": ["lhs"] }, { "name": "LiveScript", "role": "Editor", "icon": "generic.icns", "ext": ["ls", "_ls"] }, { "name": "Logos", "role": "Editor", "icon": "generic.icns", "ext": ["xm", "x", "xi"] }, { "name": "Logtalk", "role": "Editor", "icon": "generic.icns", "ext": ["lgt", "logtalk"] }, { "name": "LookML", "role": "Editor", "icon": "generic.icns", "ext": ["lookml", "model.lkml", "view.lkml"] }, { "name": "LoomScript", "role": "Editor", "icon": "generic.icns", "ext": ["ls"] }, { "name": "Lua", "role": "Editor", "icon": "generic.icns", "ext": ["lua", "fcgi", "nse", "p8", "pd_lua", "rbxs", "wlua"] }, { "name": "M", "role": "Editor", "icon": "generic.icns", "ext": ["mumps", "m"] }, { "name": "M4", "role": "Editor", "icon": "generic.icns", "ext": ["m4"] }, { "name": "M4Sugar", "role": "Editor", "icon": "generic.icns", "ext": ["m4"] }, { "name": "MATLAB", "role": "Editor", "icon": "generic.icns", "ext": ["matlab", "m"] }, { "name": "MAXScript", "role": "Editor", "icon": "generic.icns", "ext": ["ms", "mcr"] }, { "name": "MLIR", "role": "Editor", "icon": "generic.icns", "ext": ["mlir"] }, { "name": "MQL4", "role": "Editor", "icon": "generic.icns", "ext": ["mq4", "mqh"] }, { "name": "MQL5", "role": "Editor", "icon": "generic.icns", "ext": ["mq5", "mqh"] }, { "name": "MTML", "role": "Editor", "icon": "generic.icns", "ext": ["mtml"] }, { "name": "MUF", "role": "Editor", "icon": "generic.icns", "ext": ["muf", "m"] }, { "name": "Makefile", "role": "Editor", "icon": "generic.icns", "ext": ["mak", "d", "make", "mk", "mkfile"] }, { "name": "Mako", "role": "Editor", "icon": "generic.icns", "ext": ["mako", "mao"] }, { "name": "Markdown", "role": "Editor", "icon": "generic.icns", "ext": ["md", "markdown", "mdown", "mdwn", "mdx", "mkd", "mkdn", "mkdown", "ronn", "workbook"] }, { "name": "Marko", "role": "Editor", "icon": "generic.icns", "ext": ["marko"] }, { "name": "Mask", "role": "Editor", "icon": "generic.icns", "ext": ["mask"] }, { "name": "Mathematica", "role": "Editor", "icon": "generic.icns", "ext": ["mathematica", "cdf", "m", "ma", "mt", "nb", "nbp", "wl", "wlt"] }, { "name": "Max", "role": "Editor", "icon": "generic.icns", "ext": ["maxpat", "maxhelp", "maxproj", "mxt", "pat"] }, { "name": "MediaWiki", "role": "Editor", "icon": "generic.icns", "ext": ["mediawiki", "wiki"] }, { "name": "Mercury", "role": "Editor", "icon": "generic.icns", "ext": ["m", "moo"] }, { "name": "Metal", "role": "Editor", "icon": "generic.icns", "ext": ["metal"] }, { "name": "MiniD", "role": "Editor", "icon": "generic.icns", "ext": ["minid"] }, { "name": "Mirah", "role": "Editor", "icon": "generic.icns", "ext": ["druby", "duby", "mirah"] }, { "name": "Modelica", "role": "Editor", "icon": "generic.icns", "ext": ["mo"] }, { "name": "Modula-2", "role": "Editor", "icon": "generic.icns", "ext": ["mod"] }, { "name": "Modula-3", "role": "Editor", "icon": "generic.icns", "ext": ["i3", "ig", "m3", "mg"] }, { "name": "Module Management System", "role": "Editor", "icon": "generic.icns", "ext": ["mms", "mmk"] }, { "name": "Monkey", "role": "Editor", "icon": "generic.icns", "ext": ["monkey", "monkey2"] }, { "name": "Moocode", "role": "Editor", "icon": "generic.icns", "ext": ["moo"] }, { "name": "MoonScript", "role": "Editor", "icon": "generic.icns", "ext": ["moon"] }, { "name": "Motorola 68K Assembly", "role": "Editor", "icon": "generic.icns", "ext": ["X68"] }, { "name": "Muse", "role": "Editor", "icon": "generic.icns", "ext": ["muse"] }, { "name": "Myghty", "role": "Editor", "icon": "generic.icns", "ext": ["myt"] }, { "name": "NCL", "role": "Editor", "icon": "generic.icns", "ext": ["ncl"] }, { "name": "NL", "role": "Editor", "icon": "generic.icns", "ext": ["nl"] }, { "name": "NSIS", "role": "Editor", "icon": "generic.icns", "ext": ["nsi", "nsh"] }, { "name": "Nearley", "role": "Editor", "icon": "generic.icns", "ext": ["ne", "nearley"] }, { "name": "Nemerle", "role": "Editor", "icon": "generic.icns", "ext": ["n"] }, { "name": "NetLinx", "role": "Editor", "icon": "generic.icns", "ext": ["axs", "axi"] }, { "name": "NetLinx+ERB", "role": "Editor", "icon": "generic.icns", "ext": ["axs.erb", "axi.erb"] }, { "name": "NetLogo", "role": "Editor", "icon": "generic.icns", "ext": ["nlogo"] }, { "name": "NewLisp", "role": "Editor", "icon": "generic.icns", "ext": ["nl", "lisp", "lsp"] }, { "name": "Nextflow", "role": "Editor", "icon": "generic.icns", "ext": ["nf"] }, { "name": "Nginx", "role": "Editor", "icon": "generic.icns", "ext": ["nginxconf", "vhost"] }, { "name": "Nim", "role": "Editor", "icon": "generic.icns", "ext": ["nim", "nim.cfg", "nimble", "nimrod", "nims"] }, { "name": "Ninja", "role": "Editor", "icon": "generic.icns", "ext": ["ninja"] }, { "name": "Nit", "role": "Editor", "icon": "generic.icns", "ext": ["nit"] }, { "name": "Nix", "role": "Editor", "icon": "generic.icns", "ext": ["nix"] }, { "name": "Nu", "role": "Editor", "icon": "generic.icns", "ext": ["nu"] }, { "name": "NumPy", "role": "Editor", "icon": "generic.icns", "ext": ["numpy", "numpyw", "numsc"] }, { "name": "OCaml", "role": "Editor", "icon": "generic.icns", "ext": ["ml", "eliom", "eliomi", "ml4", "mli", "mll", "mly"] }, { "name": "ObjDump", "role": "Editor", "icon": "generic.icns", "ext": ["objdump"] }, { "name": "ObjectScript", "role": "Editor", "icon": "generic.icns", "ext": ["cls"] }, { "name": "Objective-C", "role": "Editor", "icon": "generic.icns", "ext": ["m", "h"] }, { "name": "Objective-C++", "role": "Editor", "icon": "generic.icns", "ext": ["mm"] }, { "name": "Objective-J", "role": "Editor", "icon": "generic.icns", "ext": ["j", "sj"] }, { "name": "Omgrofl", "role": "Editor", "icon": "generic.icns", "ext": ["omgrofl"] }, { "name": "Opa", "role": "Editor", "icon": "generic.icns", "ext": ["opa"] }, { "name": "Opal", "role": "Editor", "icon": "generic.icns", "ext": ["opal"] }, { "name": "Open Policy Agent", "role": "Editor", "icon": "generic.icns", "ext": ["rego"] }, { "name": "OpenCL", "role": "Editor", "icon": "generic.icns", "ext": ["cl", "opencl"] }, { "name": "OpenEdge ABL", "role": "Editor", "icon": "generic.icns", "ext": ["p", "cls", "w"] }, { "name": "OpenSCAD", "role": "Editor", "icon": "generic.icns", "ext": ["scad"] }, { "name": "OpenStep Property List", "role": "Editor", "icon": "generic.icns", "ext": ["plist"] }, { "name": "OpenType Feature File", "role": "Editor", "icon": "generic.icns", "ext": ["fea"] }, { "name": "Org", "role": "Editor", "icon": "generic.icns", "ext": ["org"] }, { "name": "Ox", "role": "Editor", "icon": "generic.icns", "ext": ["ox", "oxh", "oxo"] }, { "name": "Oxygene", "role": "Editor", "icon": "generic.icns", "ext": ["oxygene"] }, { "name": "Oz", "role": "Editor", "icon": "generic.icns", "ext": ["oz"] }, { "name": "P4", "role": "Editor", "icon": "generic.icns", "ext": ["p4"] }, { "name": "PHP", "role": "Editor", "icon": "generic.icns", "ext": ["php", "aw", "ctp", "fcgi", "inc", "php3", "php4", "php5", "phps", "phpt"] }, { "name": "PLSQL", "role": "Editor", "icon": "generic.icns", "ext": [ "pls", "bdy", "ddl", "fnc", "pck", "pkb", "pks", "plb", "plsql", "prc", "spc", "sql", "tpb", "tps", "trg", "vw" ] }, { "name": "PLpgSQL", "role": "Editor", "icon": "generic.icns", "ext": ["pgsql", "sql"] }, { "name": "POV-Ray SDL", "role": "Editor", "icon": "generic.icns", "ext": ["pov", "inc"] }, { "name": "Pan", "role": "Editor", "icon": "generic.icns", "ext": ["pan"] }, { "name": "Papyrus", "role": "Editor", "icon": "generic.icns", "ext": ["psc"] }, { "name": "Parrot", "role": "Editor", "icon": "generic.icns", "ext": ["parrot"] }, { "name": "Parrot Assembly", "role": "Editor", "icon": "generic.icns", "ext": ["pasm"] }, { "name": "Parrot Internal Representation", "role": "Editor", "icon": "generic.icns", "ext": ["pir"] }, { "name": "Pascal", "role": "Editor", "icon": "generic.icns", "ext": ["pas", "dfm", "dpr", "inc", "lpr", "pascal", "pp"] }, { "name": "Pawn", "role": "Editor", "icon": "generic.icns", "ext": ["pwn", "inc", "sma"] }, { "name": "Pep8", "role": "Editor", "icon": "generic.icns", "ext": ["pep"] }, { "name": "Perl", "role": "Editor", "icon": "generic.icns", "ext": ["pl", "al", "cgi", "fcgi", "perl", "ph", "plx", "pm", "psgi", "t"] }, { "name": "Perl 6", "role": "Editor", "icon": "generic.icns", "ext": ["6pl", "6pm", "nqp", "p6", "p6l", "p6m", "pl", "pl6", "pm", "pm6", "t"] }, { "name": "Pic", "role": "Editor", "icon": "generic.icns", "ext": ["pic", "chem"] }, { "name": "Pickle", "role": "Editor", "icon": "generic.icns", "ext": ["pkl"] }, { "name": "PicoLisp", "role": "Editor", "icon": "generic.icns", "ext": ["l"] }, { "name": "PigLatin", "role": "Editor", "icon": "generic.icns", "ext": ["pig"] }, { "name": "Pike", "role": "Editor", "icon": "generic.icns", "ext": ["pike", "pmod"] }, { "name": "Pod", "role": "Editor", "icon": "generic.icns", "ext": ["pod"] }, { "name": "Pod 6", "role": "Editor", "icon": "generic.icns", "ext": ["pod", "pod6"] }, { "name": "PogoScript", "role": "Editor", "icon": "generic.icns", "ext": ["pogo"] }, { "name": "Pony", "role": "Editor", "icon": "generic.icns", "ext": ["pony"] }, { "name": "PostCSS", "role": "Editor", "icon": "generic.icns", "ext": ["pcss"] }, { "name": "PostScript", "role": "Editor", "icon": "generic.icns", "ext": ["ps", "eps", "pfa"] }, { "name": "PowerBuilder", "role": "Editor", "icon": "generic.icns", "ext": ["pbt", "sra", "sru", "srw"] }, { "name": "PowerShell", "role": "Editor", "icon": "generic.icns", "ext": ["ps1", "psd1", "psm1"] }, { "name": "Prisma", "role": "Editor", "icon": "generic.icns", "ext": ["prisma"] }, { "name": "Processing", "role": "Editor", "icon": "generic.icns", "ext": ["pde"] }, { "name": "Prolog", "role": "Editor", "icon": "generic.icns", "ext": ["pl", "pro", "prolog", "yap"] }, { "name": "Propeller Spin", "role": "Editor", "icon": "generic.icns", "ext": ["spin"] }, { "name": "Protocol Buffer", "role": "Editor", "icon": "generic.icns", "ext": ["proto"] }, { "name": "Public Key", "role": "Editor", "icon": "generic.icns", "ext": ["asc", "pub"] }, { "name": "Pug", "role": "Editor", "icon": "generic.icns", "ext": ["jade", "pug"] }, { "name": "Puppet", "role": "Editor", "icon": "generic.icns", "ext": ["pp"] }, { "name": "Pure Data", "role": "Editor", "icon": "generic.icns", "ext": ["pd"] }, { "name": "PureBasic", "role": "Editor", "icon": "generic.icns", "ext": ["pb", "pbi"] }, { "name": "PureScript", "role": "Editor", "icon": "generic.icns", "ext": ["purs"] }, { "name": "Python", "role": "Editor", "icon": "generic.icns", "ext": [ "py", "bzl", "cgi", "fcgi", "gyp", "gypi", "lmi", "py3", "pyde", "pyi", "pyp", "pyt", "pyw", "rpy", "spec", "tac", "wsgi", "xpy" ] }, { "name": "Python traceback", "role": "Editor", "icon": "generic.icns", "ext": ["pytb"] }, { "name": "QML", "role": "Editor", "icon": "generic.icns", "ext": ["qml", "qbs"] }, { "name": "QMake", "role": "Editor", "icon": "generic.icns", "ext": ["pro", "pri"] }, { "name": "R", "role": "Editor", "icon": "generic.icns", "ext": ["r", "rd", "rsx"] }, { "name": "RAML", "role": "Editor", "icon": "generic.icns", "ext": ["raml"] }, { "name": "RDoc", "role": "Editor", "icon": "generic.icns", "ext": ["rdoc"] }, { "name": "REALbasic", "role": "Editor", "icon": "generic.icns", "ext": ["rbbas", "rbfrm", "rbmnu", "rbres", "rbtbar", "rbuistate"] }, { "name": "REXX", "role": "Editor", "icon": "generic.icns", "ext": ["rexx", "pprx", "rex"] }, { "name": "RHTML", "role": "Editor", "icon": "generic.icns", "ext": ["rhtml"] }, { "name": "RMarkdown", "role": "Editor", "icon": "generic.icns", "ext": ["rmd"] }, { "name": "RPC", "role": "Editor", "icon": "generic.icns", "ext": ["x"] }, { "name": "RPM Spec", "role": "Editor", "icon": "generic.icns", "ext": ["spec"] }, { "name": "RUNOFF", "role": "Editor", "icon": "generic.icns", "ext": ["rnh", "rno"] }, { "name": "Racket", "role": "Editor", "icon": "generic.icns", "ext": ["rkt", "rktd", "rktl", "scrbl"] }, { "name": "Ragel", "role": "Editor", "icon": "generic.icns", "ext": ["rl"] }, { "name": "Rascal", "role": "Editor", "icon": "generic.icns", "ext": ["rsc"] }, { "name": "Raw token data", "role": "Editor", "icon": "generic.icns", "ext": ["raw"] }, { "name": "Reason", "role": "Editor", "icon": "generic.icns", "ext": ["re", "rei"] }, { "name": "Rebol", "role": "Editor", "icon": "generic.icns", "ext": ["reb", "r", "r2", "r3", "rebol"] }, { "name": "Red", "role": "Editor", "icon": "generic.icns", "ext": ["red", "reds"] }, { "name": "Redcode", "role": "Editor", "icon": "generic.icns", "ext": ["cw"] }, { "name": "Regular Expression", "role": "Editor", "icon": "generic.icns", "ext": ["regexp", "regex"] }, { "name": "Ren'Py", "role": "Editor", "icon": "generic.icns", "ext": ["rpy"] }, { "name": "RenderScript", "role": "Editor", "icon": "generic.icns", "ext": ["rs", "rsh"] }, { "name": "Rich Text Format", "role": "Editor", "icon": "generic.icns", "ext": ["rtf"] }, { "name": "Ring", "role": "Editor", "icon": "generic.icns", "ext": ["ring"] }, { "name": "Riot", "role": "Editor", "icon": "generic.icns", "ext": ["riot"] }, { "name": "RobotFramework", "role": "Editor", "icon": "generic.icns", "ext": ["robot"] }, { "name": "Roff", "role": "Editor", "icon": "generic.icns", "ext": [ "roff", "1", "1in", "1m", "1x", "2", "3", "3in", "3m", "3p", "3pm", "3qt", "3x", "4", "5", "6", "7", "8", "9", "l", "man", "mdoc", "me", "ms", "n", "nr", "rno", "tmac" ] }, { "name": "Roff Manpage", "role": "Editor", "icon": "generic.icns", "ext": [ "1", "1in", "1m", "1x", "2", "3", "3in", "3m", "3p", "3pm", "3qt", "3x", "4", "5", "6", "7", "8", "9", "man", "mdoc" ] }, { "name": "Rouge", "role": "Editor", "icon": "generic.icns", "ext": ["rg"] }, { "name": "Ruby", "role": "Editor", "icon": "generic.icns", "ext": [ "rb", "builder", "eye", "fcgi", "gemspec", "god", "jbuilder", "mspec", "pluginspec", "podspec", "rabl", "rake", "rbuild", "rbw", "rbx", "ru", "ruby", "spec", "thor", "watchr" ] }, { "name": "Rust", "role": "Editor", "icon": "generic.icns", "ext": ["rs", "rs.in"] }, { "name": "SAS", "role": "Editor", "icon": "generic.icns", "ext": ["sas"] }, { "name": "SCSS", "role": "Editor", "icon": "generic.icns", "ext": ["scss"] }, { "name": "SMT", "role": "Editor", "icon": "generic.icns", "ext": ["smt2", "smt"] }, { "name": "SPARQL", "role": "Editor", "icon": "generic.icns", "ext": ["sparql", "rq"] }, { "name": "SQF", "role": "Editor", "icon": "generic.icns", "ext": ["sqf", "hqf"] }, { "name": "SQL", "role": "Editor", "icon": "generic.icns", "ext": ["sql", "cql", "ddl", "inc", "mysql", "prc", "tab", "udf", "viw"] }, { "name": "SQLPL", "role": "Editor", "icon": "generic.icns", "ext": ["sql", "db2"] }, { "name": "SRecode Template", "role": "Editor", "icon": "generic.icns", "ext": ["srt"] }, { "name": "STON", "role": "Editor", "icon": "generic.icns", "ext": ["ston"] }, { "name": "SVG", "role": "Editor", "icon": "generic.icns", "ext": ["svg"] }, { "name": "Sage", "role": "Editor", "icon": "generic.icns", "ext": ["sage", "sagews"] }, { "name": "SaltStack", "role": "Editor", "icon": "generic.icns", "ext": ["sls"] }, { "name": "Sass", "role": "Editor", "icon": "generic.icns", "ext": ["sass"] }, { "name": "Scala", "role": "Editor", "icon": "generic.icns", "ext": ["scala", "kojo", "sbt", "sc"] }, { "name": "Scaml", "role": "Editor", "icon": "generic.icns", "ext": ["scaml"] }, { "name": "Scheme", "role": "Editor", "icon": "generic.icns", "ext": ["scm", "sch", "sld", "sls", "sps", "ss"] }, { "name": "Scilab", "role": "Editor", "icon": "generic.icns", "ext": ["sci", "sce", "tst"] }, { "name": "Self", "role": "Editor", "icon": "generic.icns", "ext": ["self"] }, { "name": "ShaderLab", "role": "Editor", "icon": "generic.icns", "ext": ["shader"] }, { "name": "Shell", "role": "Editor", "icon": "generic.icns", "ext": ["sh", "bash", "bats", "cgi", "command", "fcgi", "ksh", "sh.in", "tmux", "tool", "zsh"] }, { "name": "ShellSession", "role": "Editor", "icon": "generic.icns", "ext": ["sh-session"] }, { "name": "Shen", "role": "Editor", "icon": "generic.icns", "ext": ["shen"] }, { "name": "Slash", "role": "Editor", "icon": "generic.icns", "ext": ["sl"] }, { "name": "Slice", "role": "Editor", "icon": "generic.icns", "ext": ["ice"] }, { "name": "Slim", "role": "Editor", "icon": "generic.icns", "ext": ["slim"] }, { "name": "SmPL", "role": "Editor", "icon": "generic.icns", "ext": ["cocci"] }, { "name": "Smali", "role": "Editor", "icon": "generic.icns", "ext": ["smali"] }, { "name": "Smalltalk", "role": "Editor", "icon": "generic.icns", "ext": ["st", "cs"] }, { "name": "Smarty", "role": "Editor", "icon": "generic.icns", "ext": ["tpl"] }, { "name": "SourcePawn", "role": "Editor", "icon": "generic.icns", "ext": ["sp", "inc"] }, { "name": "Spline Font Database", "role": "Editor", "icon": "generic.icns", "ext": ["sfd"] }, { "name": "Squirrel", "role": "Editor", "icon": "generic.icns", "ext": ["nut"] }, { "name": "Stan", "role": "Editor", "icon": "generic.icns", "ext": ["stan"] }, { "name": "Standard ML", "role": "Editor", "icon": "generic.icns", "ext": ["ML", "fun", "sig", "sml"] }, { "name": "Stata", "role": "Editor", "icon": "generic.icns", "ext": ["do", "ado", "doh", "ihlp", "mata", "matah", "sthlp"] }, { "name": "Stylus", "role": "Editor", "icon": "generic.icns", "ext": ["styl"] }, { "name": "SubRip Text", "role": "Editor", "icon": "generic.icns", "ext": ["srt"] }, { "name": "SugarSS", "role": "Editor", "icon": "generic.icns", "ext": ["sss"] }, { "name": "SuperCollider", "role": "Editor", "icon": "generic.icns", "ext": ["sc", "scd"] }, { "name": "Svelte", "role": "Editor", "icon": "generic.icns", "ext": ["svelte"] }, { "name": "Swift", "role": "Editor", "icon": "generic.icns", "ext": ["swift"] }, { "name": "SystemVerilog", "role": "Editor", "icon": "generic.icns", "ext": ["sv", "svh", "vh"] }, { "name": "TI Program", "role": "Editor", "icon": "generic.icns", "ext": ["8xp", "8xk", "8xk.txt", "8xp.txt"] }, { "name": "TLA", "role": "Editor", "icon": "generic.icns", "ext": ["tla"] }, { "name": "TOML", "role": "Editor", "icon": "generic.icns", "ext": ["toml"] }, { "name": "TSQL", "role": "Editor", "icon": "generic.icns", "ext": ["sql"] }, { "name": "TSX", "role": "Editor", "icon": "generic.icns", "ext": ["tsx"] }, { "name": "TXL", "role": "Editor", "icon": "generic.icns", "ext": ["txl"] }, { "name": "Tcl", "role": "Editor", "icon": "generic.icns", "ext": ["tcl", "adp", "tm"] }, { "name": "Tcsh", "role": "Editor", "icon": "generic.icns", "ext": ["tcsh", "csh"] }, { "name": "TeX", "role": "Editor", "icon": "generic.icns", "ext": [ "tex", "aux", "bbx", "cbx", "cls", "dtx", "ins", "lbx", "ltx", "mkii", "mkiv", "mkvi", "sty", "toc" ] }, { "name": "Tea", "role": "Editor", "icon": "generic.icns", "ext": ["tea"] }, { "name": "Terra", "role": "Editor", "icon": "generic.icns", "ext": ["t"] }, { "name": "Texinfo", "role": "Editor", "icon": "generic.icns", "ext": ["texinfo", "texi", "txi"] }, { "name": "Text", "role": "Editor", "icon": "generic.icns", "ext": ["txt", "fr", "nb", "ncl", "no"] }, { "name": "Textile", "role": "Editor", "icon": "generic.icns", "ext": ["textile"] }, { "name": "Thrift", "role": "Editor", "icon": "generic.icns", "ext": ["thrift"] }, { "name": "Turing", "role": "Editor", "icon": "generic.icns", "ext": ["t", "tu"] }, { "name": "Turtle", "role": "Editor", "icon": "generic.icns", "ext": ["ttl"] }, { "name": "Twig", "role": "Editor", "icon": "generic.icns", "ext": ["twig"] }, { "name": "Type Language", "role": "Editor", "icon": "generic.icns", "ext": ["tl"] }, { "name": "TypeScript", "role": "Editor", "icon": "generic.icns", "ext": ["ts"] }, { "name": "Unified Parallel C", "role": "Editor", "icon": "generic.icns", "ext": ["upc"] }, { "name": "Unity3D Asset", "role": "Editor", "icon": "generic.icns", "ext": ["anim", "asset", "mat", "meta", "prefab", "unity"] }, { "name": "Unix Assembly", "role": "Editor", "icon": "generic.icns", "ext": ["s", "ms"] }, { "name": "Uno", "role": "Editor", "icon": "generic.icns", "ext": ["uno"] }, { "name": "UnrealScript", "role": "Editor", "icon": "generic.icns", "ext": ["uc"] }, { "name": "UrWeb", "role": "Editor", "icon": "generic.icns", "ext": ["ur", "urs"] }, { "name": "V", "role": "Editor", "icon": "generic.icns", "ext": ["v"] }, { "name": "VCL", "role": "Editor", "icon": "generic.icns", "ext": ["vcl"] }, { "name": "VHDL", "role": "Editor", "icon": "generic.icns", "ext": ["vhdl", "vhd", "vhf", "vhi", "vho", "vhs", "vht", "vhw"] }, { "name": "Vala", "role": "Editor", "icon": "generic.icns", "ext": ["vala", "vapi"] }, { "name": "Verilog", "role": "Editor", "icon": "generic.icns", "ext": ["v", "veo"] }, { "name": "Vim Snippet", "role": "Editor", "icon": "generic.icns", "ext": ["snip", "snippet", "snippets"] }, { "name": "Vim script", "role": "Editor", "icon": "generic.icns", "ext": ["vim", "vba", "vmb"] }, { "name": "Visual Basic", "role": "Editor", "icon": "generic.icns", "ext": ["vb", "bas", "cls", "frm", "frx", "vba", "vbhtml", "vbs"] }, { "name": "Volt", "role": "Editor", "icon": "generic.icns", "ext": ["volt"] }, { "name": "Vue", "role": "Editor", "icon": "generic.icns", "ext": ["vue"] }, { "name": "Wavefront Material", "role": "Editor", "icon": "generic.icns", "ext": ["mtl"] }, { "name": "Wavefront Object", "role": "Editor", "icon": "generic.icns", "ext": ["obj"] }, { "name": "Web Ontology Language", "role": "Editor", "icon": "generic.icns", "ext": ["owl"] }, { "name": "WebAssembly", "role": "Editor", "icon": "generic.icns", "ext": ["wast", "wat"] }, { "name": "WebIDL", "role": "Editor", "icon": "generic.icns", "ext": ["webidl"] }, { "name": "WebVTT", "role": "Editor", "icon": "generic.icns", "ext": ["vtt"] }, { "name": "Windows Registry Entries", "role": "Editor", "icon": "generic.icns", "ext": ["reg"] }, { "name": "Wollok", "role": "Editor", "icon": "generic.icns", "ext": ["wlk"] }, { "name": "World of Warcraft Addon Data", "role": "Editor", "icon": "generic.icns", "ext": ["toc"] }, { "name": "X BitMap", "role": "Editor", "icon": "generic.icns", "ext": ["xbm"] }, { "name": "X PixMap", "role": "Editor", "icon": "generic.icns", "ext": ["xpm", "pm"] }, { "name": "X10", "role": "Editor", "icon": "generic.icns", "ext": ["x10"] }, { "name": "XC", "role": "Editor", "icon": "generic.icns", "ext": ["xc"] }, { "name": "XML", "role": "Editor", "icon": "generic.icns", "ext": [ "xml", "adml", "admx", "ant", "axml", "builds", "ccproj", "ccxml", "clixml", "cproject", "cscfg", "csdef", "csl", "csproj", "ct", "depproj", "dita", "ditamap", "ditaval", "dll.config", "dotsettings", "filters", "fsproj", "fxml", "glade", "gml", "gmx", "grxml", "iml", "ivy", "jelly", "jsproj", "kml", "launch", "mdpolicy", "mjml", "mm", "mod", "mxml", "natvis", "ncl", "ndproj", "nproj", "nuspec", "odd", "osm", "pkgproj", "pluginspec", "proj", "props", "ps1xml", "psc1", "pt", "rdf", "resx", "rss", "sch", "scxml", "sfproj", "shproj", "srdf", "storyboard", "sublime-snippet", "targets", "tml", "ts", "tsx", "ui", "urdf", "ux", "vbproj", "vcxproj", "vsixmanifest", "vssettings", "vstemplate", "vxml", "wixproj", "workflow", "wsdl", "wsf", "wxi", "wxl", "wxs", "x3d", "xacro", "xaml", "xib", "xlf", "xliff", "xmi", "xml.dist", "xproj", "xsd", "xspec", "xul", "zcml" ] }, { "name": "XML Property List", "role": "Editor", "icon": "generic.icns", "ext": ["plist", "stTheme", "tmCommand", "tmLanguage", "tmPreferences", "tmSnippet", "tmTheme"] }, { "name": "XPages", "role": "Editor", "icon": "generic.icns", "ext": ["xsp-config", "xsp.metadata"] }, { "name": "XProc", "role": "Editor", "icon": "generic.icns", "ext": ["xpl", "xproc"] }, { "name": "XQuery", "role": "Editor", "icon": "generic.icns", "ext": ["xquery", "xq", "xql", "xqm", "xqy"] }, { "name": "XS", "role": "Editor", "icon": "generic.icns", "ext": ["xs"] }, { "name": "XSLT", "role": "Editor", "icon": "generic.icns", "ext": ["xslt", "xsl"] }, { "name": "Xojo", "role": "Editor", "icon": "generic.icns", "ext": ["xojo_code", "xojo_menu", "xojo_report", "xojo_script", "xojo_toolbar", "xojo_window"] }, { "name": "Xtend", "role": "Editor", "icon": "generic.icns", "ext": ["xtend"] }, { "name": "YAML", "role": "Editor", "icon": "generic.icns", "ext": [ "yml", "mir", "reek", "rviz", "sublime-syntax", "syntax", "yaml", "yaml-tmlanguage", "yml.mysql" ] }, { "name": "YANG", "role": "Editor", "icon": "generic.icns", "ext": ["yang"] }, { "name": "YARA", "role": "Editor", "icon": "generic.icns", "ext": ["yar", "yara"] }, { "name": "YASnippet", "role": "Editor", "icon": "generic.icns", "ext": ["yasnippet"] }, { "name": "Yacc", "role": "Editor", "icon": "generic.icns", "ext": ["y", "yacc", "yy"] }, { "name": "ZAP", "role": "Editor", "icon": "generic.icns", "ext": ["zap", "xzap"] }, { "name": "ZIL", "role": "Editor", "icon": "generic.icns", "ext": ["zil", "mud"] }, { "name": "Zeek", "role": "Editor", "icon": "generic.icns", "ext": ["zeek", "bro"] }, { "name": "ZenScript", "role": "Editor", "icon": "generic.icns", "ext": ["zs"] }, { "name": "Zephir", "role": "Editor", "icon": "generic.icns", "ext": ["zep"] }, { "name": "Zig", "role": "Editor", "icon": "generic.icns", "ext": ["zig"] }, { "name": "Zimpl", "role": "Editor", "icon": "generic.icns", "ext": ["zimpl", "zmpl", "zpl"] }, { "name": "desktop", "role": "Editor", "icon": "generic.icns", "ext": ["desktop", "desktop.in"] }, { "name": "eC", "role": "Editor", "icon": "generic.icns", "ext": ["ec", "eh"] }, { "name": "edn", "role": "Editor", "icon": "generic.icns", "ext": ["edn"] }, { "name": "fish", "role": "Editor", "icon": "generic.icns", "ext": ["fish"] }, { "name": "mIRC Script", "role": "Editor", "icon": "generic.icns", "ext": ["mrc"] }, { "name": "mcfunction", "role": "Editor", "icon": "generic.icns", "ext": ["mcfunction"] }, { "name": "mupad", "role": "Editor", "icon": "generic.icns", "ext": ["mu"] }, { "name": "nanorc", "role": "Editor", "icon": "generic.icns", "ext": ["nanorc"] }, { "name": "nesC", "role": "Editor", "icon": "generic.icns", "ext": ["nc"] }, { "name": "ooc", "role": "Editor", "icon": "generic.icns", "ext": ["ooc"] }, { "name": "q", "role": "Editor", "icon": "generic.icns", "ext": ["q"] }, { "name": "reStructuredText", "role": "Editor", "icon": "generic.icns", "ext": ["rst", "rest", "rest.txt", "rst.txt"] }, { "name": "sed", "role": "Editor", "icon": "generic.icns", "ext": ["sed"] }, { "name": "wdl", "role": "Editor", "icon": "generic.icns", "ext": ["wdl"] }, { "name": "wisp", "role": "Editor", "icon": "generic.icns", "ext": ["wisp"] }, { "name": "xBase", "role": "Editor", "icon": "generic.icns", "ext": ["prg", "ch", "prw"] } ] ================================================ FILE: packages/electron/config/electron-builder/release.js ================================================ // Notarize needs APP_ID, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, TEAM_ID env variables. // Github repo to release is automatically detected from package.json. // GH_TOKEN env variable is required to upload release. // eslint-disable-next-line import/extensions const build = require('./build.js'); const publish = { ...build, mac: { category: 'public.app-category.developer-tools', target: { target: 'default', arch: 'universal', }, notarize: { teamId: process.env.TEAM_ID, }, }, }; module.exports = publish; ================================================ FILE: packages/electron/config/webpack.common.config.js ================================================ const path = require('path'); const buildPath = path.resolve(__dirname, './../build'); module.exports = { mode: 'development', output: { path: buildPath, }, resolve: { extensions: ['.ts', '.js'], }, module: { rules: [ { test: /\.(js|ts)$/, exclude: /node_modules/, loader: 'babel-loader', }, ], }, }; ================================================ FILE: packages/electron/config/webpack.config.js ================================================ const rendererConfig = require('./webpack.renderer.config'); const mainConfig = require('./webpack.main.config'); module.exports = [rendererConfig, mainConfig]; ================================================ FILE: packages/electron/config/webpack.main.config.js ================================================ const { merge } = require('webpack-merge'); const common = require('./webpack.common.config'); const config = merge(common, { entry: './src/main/index.ts', output: { filename: 'main.js', }, target: 'electron-main', node: { __dirname: false, __filename: false, }, }); module.exports = config; ================================================ FILE: packages/electron/config/webpack.prod.config.js ================================================ const { merge } = require('webpack-merge'); const rendererConfig = require('./webpack.renderer.config'); const mainConfig = require('./webpack.main.config'); const prod = { mode: 'production', }; const rendererConfigProd = merge(rendererConfig, prod); const mainConfigProd = merge(mainConfig, prod); module.exports = [rendererConfigProd, mainConfigProd]; ================================================ FILE: packages/electron/config/webpack.renderer.config.js ================================================ const { merge } = require('webpack-merge'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const common = require('./webpack.common.config'); const config = merge(common, { entry: './src/renderer/index.ts', output: { filename: 'renderer.js', }, plugins: [ new HtmlWebpackPlugin({ template: './src/renderer/index.html', }), ], target: 'web', devtool: 'eval-cheap-source-map', }); module.exports = config; ================================================ FILE: packages/electron/jest.config.js ================================================ module.exports = { clearMocks: true, moduleNameMapper: { 'src/(.*)': ['/src/$1'], }, }; ================================================ FILE: packages/electron/package.json ================================================ { "name": "@vvim/electron", "description": "Neovim GUI Client", "author": "Igor Gladkoborodov ", "version": "2.6.2", "private": true, "keywords": [ "vim", "neovim", "client", "gui", "electron" ], "repository": { "type": "git", "url": "https://github.com:vv-vim/vv.git" }, "license": "MIT", "main": "./build/main.js", "sideEffects": false, "scripts": { "test": "jest", "clean": "rm -rf dist/*", "webpack:dev": "webpack --watch --config ./config/webpack.config.js", "webpack:prod": "webpack --config ./config/webpack.prod.config.js", "build:local": "yarn webpack:prod; electron-builder -c.mac.identity=null -c.extraMetadata.main=build/main.js --config config/electron-builder/build.js", "build:release": "electron-builder -c.extraMetadata.main=build/main.js --config config/electron-builder/release.js --publish always", "build:link": "rm -rf /Applications/VV.app; cp -R dist/mac-universal/VV.app /Applications; ln -s -f /Applications/VV.app/Contents/Resources/bin/vv /usr/local/bin/vv", "build": "npm-run-all clean webpack:prod build:local build:link", "release:open-github": "open https://github.com/vv-vim/vv/releases", "release": "npm-run-all clean webpack:prod build:release release:open-github", "filetypes": "node scripts/filetypes.js", "dev": "yarn webpack:dev", "start": "electron ." }, "browserslist": [ "chrome 122", "node 20" ], "devDependencies": { "@types/jest": "^26.0.20", "@types/lodash": "^4.14.168", "@types/node": "^16.0.0", "chalk": "^4.1.0", "dotenv": "^8.2.0", "electron": "^29", "electron-builder": "^24.13.3", "html-webpack-plugin": "^5.6.0", "js-yaml": "^3.14.0", "node-fetch": "^2.6.7" }, "dependencies": { "@vvim/browser-renderer": "0.0.1", "@vvim/nvim": "0.0.1", "electron-store": "^7.0.2", "electron-updater": "^4.3.5", "emoji-regex": "^10.3.0", "html2plaintext": "^2.1.2", "lodash": "^4.17.21", "semver": "^7.5.2" } } ================================================ FILE: packages/electron/scripts/filetypes.js ================================================ // Generate fileAssociations for electron-builder. // File types are generated from [Github Linguist](https://github.com/github/linguist) // languates list. const fetch = require('node-fetch'); const yaml = require('js-yaml'); const fs = require('fs'); const path = require('path'); const SOURCE_YAML = 'https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml'; const SAVE_TO = path.join(__dirname, '../config/electron-builder/fileAssociations.json'); const filetypes = async () => { const yamlDoc = await fetch(SOURCE_YAML).then((res) => res.text()); const parsed = yaml.safeLoad(yamlDoc); const fileAssociations = Object.keys(parsed) .filter((key) => parsed[key].extensions) .map((key) => ({ name: key, role: 'Editor', icon: 'generic.icns', ext: parsed[key].extensions.map((e) => e.replace('.', '')), })); fs.writeFileSync(SAVE_TO, JSON.stringify(fileAssociations, null, 2), { encoding: 'utf-8' }); }; filetypes(); ================================================ FILE: packages/electron/src/lib/isDev.ts ================================================ type IsDevFunction = { (dev: T, notDev: F): T | F; (): boolean; }; const isDev: IsDevFunction = (dev = true, notDev = false) => process.env.NODE_ENV === 'development' ? dev : notDev; export default isDev; ================================================ FILE: packages/electron/src/lib/log.ts ================================================ const initNow = Date.now(); let lastNow = initNow; const log = (...text: string[]): void => { // eslint-disable-next-line no-console console.log(...text, Date.now() - lastNow, Date.now() - initNow, initNow, Date.now()); lastNow = Date.now(); }; log('Init log'); export default log; ================================================ FILE: packages/electron/src/main/autoUpdate.ts ================================================ import { dialog, BrowserWindow } from 'electron'; import { autoUpdater } from 'electron-updater'; import html2plaintext from 'html2plaintext'; import { getSettings, onChangeSettings, SettingsCallback } from 'src/main/nvim/settings'; import store from 'src/main/lib/store'; let interval = 0; let updaterIntervalId: NodeJS.Timeout; const LAST_CHECKED = 'autoUpdate.lastCheckedForUpdate'; const MINUTE = 60 * 1000; const needToCheck = () => { if (interval === 0) { return false; } const lastChecked = store.get(LAST_CHECKED); if (!lastChecked) { return true; } return Date.now() - lastChecked > interval * MINUTE; }; const updater = () => { if (needToCheck()) { store.set(LAST_CHECKED, Date.now()); autoUpdater.checkForUpdates(); } }; const startUpdater = () => { if (!updaterIntervalId) { updaterIntervalId = setInterval(updater, MINUTE); } }; const updateInterval = (newInterval: string) => { if (interval !== parseInt(newInterval, 10)) { interval = parseInt(newInterval, 10); startUpdater(); } }; const handleChangeSettings: SettingsCallback = (_newSettings, allSettings) => { const { autoupdateinterval } = allSettings; if (autoupdateinterval !== undefined) { updateInterval(autoupdateinterval); } }; const handleUpdateAvailable = ({ version, releaseNotes, }: { version: string; releaseNotes: string; }) => { const response = dialog.showMessageBoxSync({ type: 'question', buttons: ['Update', 'Ignore'], defaultId: 0, message: `Version ${version} is available, do you want to install it now?`, detail: html2plaintext(releaseNotes), title: 'Update available', }); if (response === 0) { autoUpdater.downloadUpdate(); } }; const handleUpdateDownloaded = () => { dialog.showMessageBox({ type: 'question', buttons: ['OK'], defaultId: 0, message: `Update Downloaded`, detail: 'Please restart app to install update.', title: 'Update Downloaded', }); }; const initAutoUpdate = ({ win }: { win: BrowserWindow }): void => { updateInterval(getSettings().autoupdateinterval); onChangeSettings(win, handleChangeSettings); autoUpdater.autoDownload = false; autoUpdater.on('update-available', handleUpdateAvailable); autoUpdater.on('update-downloaded', handleUpdateDownloaded); }; export default initAutoUpdate; ================================================ FILE: packages/electron/src/main/checkNeovim.ts ================================================ import { app, dialog, shell } from 'electron'; import semver from 'semver'; import { nvimVersion } from '@vvim/nvim'; const REQUIRED_VERSION = '0.4.0'; const checkNeovim = (): void => { const version = nvimVersion(); if (!version) { const result = dialog.showMessageBoxSync({ message: 'Neovim is not installed', detail: `VV requires Neovim. You can install it via Homebrew: brew install neovim Or you can find Neovim installation instructions here: https://github.com/neovim/neovim/wiki/Installing-Neovim `, defaultId: 0, buttons: ['Open Installation Instructions', 'Close'], }); if (result === 0) { shell.openExternal('https://github.com/neovim/neovim/wiki/Installing-Neovim'); } app.exit(); } else if (semver.lt(version, REQUIRED_VERSION)) { const result = dialog.showMessageBoxSync({ message: 'Neovim is outdated', detail: `VV requires Neovim version ${REQUIRED_VERSION} and later. You have ${version}. If you installed Neovim via Homebrew, please run: brew upgrade neovim Otherwise please check installation instructions here: https://github.com/neovim/neovim/wiki/Installing-Neovim `, defaultId: 0, buttons: ['Open Installation Instructions', 'Close'], }); if (result === 0) { shell.openExternal('https://github.com/neovim/neovim/wiki/Installing-Neovim'); } app.exit(); } }; export default checkNeovim; ================================================ FILE: packages/electron/src/main/index.ts ================================================ import { app, BrowserWindow, dialog } from 'electron'; import { statSync, existsSync } from 'fs'; import { join, resolve } from 'path'; import isDev from 'src/lib/isDev'; import menu from 'src/main/menu'; import installCli from 'src/main/installCli'; import checkNeovim from 'src/main/checkNeovim'; import { setShouldQuit } from 'src/main/nvim/features/quit'; import { getSettings } from 'src/main/nvim/settings'; import { getNvimByWindow } from 'src/main/nvim/nvimByWindow'; import initAutoUpdate from 'src/main/autoUpdate'; import initNvim from 'src/main/nvim/nvim'; import { parseArgs, joinArgs, filterArgs, cliArgs, argValue } from 'src/main/lib/args'; import IpcTransport from 'src/main/transport/ipc'; let currentWindow: BrowserWindow | undefined | null; const windows: BrowserWindow[] = []; /** Empty windows created in advance to make windows creation faster */ const emptyWindows: BrowserWindow[] = []; app.commandLine.appendSwitch('force_high_performance_gpu'); const openDeveloperTools = (win: BrowserWindow) => { win.webContents.openDevTools({ mode: 'detach' }); win.webContents.on('devtools-opened', () => { win.webContents.focus(); }); }; const handleAllClosed = () => { const { quitoncloselastwindow } = getSettings(); if (quitoncloselastwindow || process.platform !== 'darwin') { app.quit(); } }; const createEmptyWindow = (isDebug = false) => { const options = { width: 800, height: 600, show: isDebug, fullscreenable: false, // frame: false, // roundedCorners: false, webPreferences: { preload: join(app.getAppPath(), isDev('./', '../'), 'src/main/preload.js'), }, }; let win = new BrowserWindow(options); // @ts-expect-error TODO win.zoomLevel = 0; win.on('closed', async () => { if (currentWindow === win) currentWindow = null; const i = windows.indexOf(win); if (i !== -1) windows.splice(i, 1); // @ts-expect-error TODO win = null; if (windows.length === 0) handleAllClosed(); }); win.on('focus', () => { currentWindow = win; }); win.loadURL( process.env.DEV_SERVER ? 'http://localhost:3000' : `file://${join(__dirname, './index.html')}`, ); return win; }; const getEmptyWindow = (isDebug = false): BrowserWindow => { if (emptyWindows.length > 0) { return emptyWindows.pop() as BrowserWindow; } return createEmptyWindow(isDebug); }; const createWindow = async (originalArgs: string[] = [], newCwd?: string) => { const settings = getSettings(); const cwd = newCwd || process.cwd(); const isDebug = originalArgs.includes('--debug') || originalArgs.includes('--inspect'); // TODO: Use yargs maybe. const { args, files } = parseArgs(filterArgs(originalArgs)); let unopenedFiles = files; let { openInProject } = settings; let openInProjectArg = argValue(originalArgs, '--open-in-project'); if (openInProjectArg === '0' || openInProjectArg === 'false') { openInProjectArg = undefined; openInProject = 0; } if (openInProjectArg === 'true') { openInProjectArg = '1'; } // TODO: Rafactor this somewhere to a separate file or function. if (openInProject || openInProjectArg) { await Promise.all( windows.map(async (win) => { const nvim = getNvimByWindow(win); if (nvim) { // @ts-expect-error TODO: don't add custom props to win win.cwd = await nvim.callFunction('VVprojectRoot', []); // eslint-disable-line } return Promise.resolve(); }), ); unopenedFiles = files.reduce((result, fileName) => { const resolvedFileName = resolve(cwd, fileName); const openInWindow = windows.find( // @ts-expect-error TODO: don't add custom props to win (w) => resolvedFileName.startsWith(w.cwd) && !w.isMinimized(), ); if (openInWindow) { const nvim = getNvimByWindow(openInWindow); if (nvim) { // @ts-expect-error TODO: don't add custom props to win const relativeFileName = resolvedFileName.substring(openInWindow.cwd.length + 1); nvim.callFunction( 'VVopenInProject', openInProjectArg ? [relativeFileName, openInProjectArg] : [relativeFileName], ); openInWindow.focus(); app.focus({ steal: true }); return result; } } return [...result, fileName]; }, []); } if (files.length === 0 || unopenedFiles.length > 0) { const win = getEmptyWindow(isDebug); // @ts-expect-error TODO: don't add custom props to win win.cwd = cwd; if (currentWindow && !currentWindow.isFullScreen() && !currentWindow.isSimpleFullScreen()) { const [x, y] = currentWindow.getPosition(); const [width, height] = currentWindow.getSize(); win.setBounds({ x: x + 20, y: y + 20, width, height }, false); } const transport = new IpcTransport(win); initNvim({ args: joinArgs({ args, files: unopenedFiles }), cwd, win, transport, }); const initRenderer = () => transport.send('initRenderer', settings); if (win.webContents.isLoading()) { win.webContents.on('did-finish-load', initRenderer); } else { initRenderer(); } win.focus(); windows.push(win); if (isDebug) { openDeveloperTools(win); } else { setTimeout(() => emptyWindows.push(createEmptyWindow()), 1000); } initAutoUpdate({ win }); } }; const openFileOrDir = (fileName: string) => { app.addRecentDocument(fileName); if (existsSync(fileName) && statSync(fileName).isDirectory()) { createWindow([fileName], fileName); } else { createWindow([fileName]); } }; const openFile = () => { const fileNames = dialog.showOpenDialogSync({ properties: ['openFile', 'openDirectory', 'createDirectory', 'multiSelections'], }); if (fileNames) { fileNames.forEach(openFileOrDir); } }; const gotTheLock = isDev() || app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { let fileToOpen: string | undefined | null; app.on('will-finish-launching', () => { app.on('open-file', (_e, file) => { fileToOpen = file; }); }); app.on('ready', () => { checkNeovim(); if (fileToOpen) { openFileOrDir(fileToOpen); fileToOpen = null; } else { createWindow(cliArgs()); } menu({ createWindow, openFile, installCli: installCli(join(app.getAppPath(), '../bin/vv')), }); app.on('open-file', (_e, file) => openFileOrDir(file)); app.focus(); }); app.on('second-instance', (_e, args, cwd) => { createWindow(cliArgs(args), cwd); }); app.on('before-quit', (e) => { setShouldQuit(true); const visibleWindows = windows.filter((w) => w.isVisible()); if (visibleWindows.length > 0) { e.preventDefault(); (currentWindow || visibleWindows[0]).close(); } }); app.on('window-all-closed', handleAllClosed); app.on('activate', (_e, hasVisibleWindows) => { if (!hasVisibleWindows) { createWindow(); } }); } ================================================ FILE: packages/electron/src/main/installCli.ts ================================================ import { dialog } from 'electron'; import { execSync } from 'child_process'; import which from 'src/main/lib/which'; const showInstallCliDialog = () => dialog.showMessageBoxSync({ message: 'Command line launcher', detail: `With command line launcher you can run VV from terminal: $ vv [filename] Do you wish to install it? It will be placed to /usr/local/bin. `, cancelId: 1, defaultId: 0, buttons: ['Install', 'Cancel'], }); const showCliInstalledDialog = (message: string, path: string) => dialog.showMessageBox({ message, detail: `Command line launcher installed at ${path}. You can run VV from terminal by typing: $ vv [filename] `, defaultId: 0, buttons: ['Ok'], }); const showErrorDialog = (error: Error) => { dialog.showMessageBox({ message: 'Error', detail: error.message, defaultId: 0, buttons: ['Ok'], }); }; const installCli = (binPath: string) => (): void => { let path = which('vv'); if (path && path.indexOf('VV.app/Contents/MacOS/vv') === -1) { path = path.replace('\n', ''); showCliInstalledDialog('Command Line Launcher', path); } else { const response = showInstallCliDialog(); if (response === 0) { try { execSync(`ln -sf ${binPath} /usr/local/bin/`); } catch (error) { showErrorDialog(error); return; } showCliInstalledDialog('Done', '/usr/local/bin/vv'); } } }; export default installCli; ================================================ FILE: packages/electron/src/main/lib/__tests__/args.test.ts ================================================ import { parseArgs, joinArgs, filterArgs, argValue } from 'src/main/lib/args'; describe('parseArgs', () => { test('return empty array if input is empty', () => { expect(parseArgs([])).toEqual({ args: [], files: [] }); expect(parseArgs()).toEqual({ args: [], files: [] }); }); test('returns everything if there ar no params', () => { expect(parseArgs(['file1', 'file2'])).toEqual({ args: [], files: ['file1', 'file2'], }); }); test('returns everything after --', () => { expect(parseArgs(['before1', 'before2', '--', 'after1', 'after2'])).toEqual({ args: ['before1', 'before2'], files: ['after1', 'after2'], }); }); test('skip params started with - or +', () => { ['-param1', '--param2', '+cmd1'].forEach((param) => { expect(parseArgs([param, 'file1', 'file2'])).toEqual({ args: [param], files: ['file1', 'file2'], }); }); }); test('skip params with argument', () => { ['--cmd', '-c', '-i', '-r', '-s', '-S', '-u', '--listen', '--startuptime'].forEach((param) => { expect(parseArgs([param, 'arg', 'file1', 'file2'])).toEqual({ args: [param, 'arg'], files: ['file1', 'file2'], }); }); }); test('does not mutate arguments', () => { const args = ['arg1', 'arg2']; parseArgs(args); expect(args).toEqual(['arg1', 'arg2']); }); }); describe('joinArgs', () => { test('joins args and files arrays and put -- between them', () => { expect(joinArgs({ args: ['arg1', 'arg2'], files: ['file1', 'file2'] })).toEqual([ 'arg1', 'arg2', '--', 'file1', 'file2', ]); }); test("don't add -- if args is empty", () => { expect(joinArgs({ args: [], files: ['file1', 'file2'] })).toEqual(['file1', 'file2']); }); test("don't add -- if files is empty", () => { expect(joinArgs({ args: ['arg1'], files: [] })).toEqual(['arg1']); }); }); describe('filterArgs', () => { test('returns all args if none of them are VV-specific', () => { expect(filterArgs(['arg1', 'arg2'])).toEqual(['arg1', 'arg2']); }); test('filters out --inspect', () => { expect(filterArgs(['arg1', '--inspect', 'arg2'])).toEqual(['arg1', 'arg2']); expect(filterArgs(['--inspect', 'arg1', 'arg2'])).toEqual(['arg1', 'arg2']); expect(filterArgs(['arg1', 'arg2', '--inspect'])).toEqual(['arg1', 'arg2']); expect(filterArgs(['--inspect'])).toEqual([]); }); test('filters out --open-in-project with value', () => { expect(filterArgs(['arg1', '--open-in-project', 'value', 'arg2'])).toEqual(['arg1', 'arg2']); expect(filterArgs(['--open-in-project', 'value', 'arg1', 'arg2'])).toEqual(['arg1', 'arg2']); expect(filterArgs(['arg1', 'arg2', '--open-in-project'])).toEqual(['arg1', 'arg2']); expect(filterArgs(['--open-in-project'])).toEqual([]); expect(filterArgs(['--open-in-project', 'value'])).toEqual([]); }); test('filters out chromium flags', () => { expect( filterArgs(['arg1', '--allow-file-access-from-files', '--enable-avfoundation', 'arg2']), ).toEqual(['arg1', 'arg2']); }); }); describe('argValue', () => { test('returns true if argument is present', () => { expect(argValue(['--arg1', '--arg2'], '--arg1')).toBe(true); expect(argValue(['--arg1', '--arg2', 'file1'], '--arg1')).toBe(true); expect(argValue(['--arg1', '--arg2', '--', 'file1'], '--arg1')).toBe(true); }); test('returns undefined if argument is not present', () => { expect(argValue(['--arg1', '--arg2'], '--arg3')).toBeUndefined(); expect(argValue(['--arg1', '--', '--arg2'], '--arg2')).toBeUndefined(); }); test('returns value for argument with param', () => { expect(argValue(['--arg1', '--cmd', 'cmdValue', '--arg2'], '--cmd')).toBe('cmdValue'); expect(argValue(['--cmd', 'cmdValue'], '--cmd')).toBe('cmdValue'); expect(argValue(['--cmd', 'cmdValue', 'file1'], '--cmd')).toBe('cmdValue'); expect(argValue(['--cmd', 'cmdValue', '--', '--cmd', 'invalid'], '--cmd')).toBe('cmdValue'); }); test('returns undefined invalid argument with param', () => { expect(argValue(['--arg1', '--cmd'], '--cmd')).toBeUndefined(); expect(argValue(['--', '--cmd', 'cmdValue'], '--cmd')).toBeUndefined(); }); }); ================================================ FILE: packages/electron/src/main/lib/args.ts ================================================ // TODO: Use commander or yargs import isDev from 'src/lib/isDev'; const ARGS_WITH_PARAM = [ '--cmd', '-c', '-i', '-r', '-s', '-S', '-u', '--listen', '--startuptime', '--open-in-project', ]; /** * Args specific to VV. */ const VV_ARGS = ['--debug', '--inspect', '--open-in-project']; /** * Chromium args added by electron. * TODO: find more reliable way to filter them. */ const CHROMIUM_ARGS = [ '--allow-file-access-from-files', '--enable-avfoundation', '--force_high_performance_gpu', ]; /** * Parse CLI args and return the list of files and arguments. */ export const parseArgs = ( originalArgs: string[] = [], ): { args: string[]; files: string[]; } => { const args = [...originalArgs]; const filesSeparator = args.indexOf('--'); if (filesSeparator !== -1) { return { args: args.slice(0, filesSeparator), files: args.slice(filesSeparator + 1), }; } const files: string[] = []; for (let i = args.length - 1; i >= 0; i -= 1) { if (['-', '+'].includes(args[i][0]) || (args[i - 1] && ARGS_WITH_PARAM.includes(args[i - 1]))) { break; } files.unshift(args.pop() as string); } return { args, files }; }; /** * Join previously parsed args. */ export const joinArgs = ({ args, files }: { args: string[]; files: string[] }): string[] => { if (args.length === 0) { return files; } if (files.length === 0) { return args; } return [...args, '--', ...files]; }; /** * Argument value. * Returns true for argument that does not require argument if it is present. * Returns argument param for argumenst with params (for example --cmd). * Undefined if param is not present. */ export const argValue = (originalArgs: string[], argName: string): string | true | undefined => { const { args } = parseArgs(originalArgs); const index = args.indexOf(argName); if (index === -1) { return undefined; } if (ARGS_WITH_PARAM.includes(argName)) { return args[index + 1]; } return true; }; /** * Remove VV specific arguments not supported by nvim */ export const filterArgs = (args: string[]): string[] => args.reduce((result, a, i) => { if (VV_ARGS.includes(a) || CHROMIUM_ARGS.includes(a)) { return result; } if (args[i - 1] && VV_ARGS.includes(args[i - 1]) && ARGS_WITH_PARAM.includes(args[i - 1])) { return result; } return [...result, a]; }, []); /** * Get CLI arguments */ export const cliArgs = (args?: string[]): string[] | undefined => (args || process.argv).slice(isDev(2, 1)); ================================================ FILE: packages/electron/src/main/lib/store.ts ================================================ import Store from 'electron-store'; type BooleanSetting = 0 | 1; export type Settings = { fullscreen: BooleanSetting; simplefullscreen: BooleanSetting; bold: BooleanSetting; italic: BooleanSetting; underline: BooleanSetting; undercurl: BooleanSetting; strikethrough: BooleanSetting; fontfamily: string; fontsize: string; // TODO: number lineheight: string; // TODO: number letterspacing: string; // TODO: number reloadchanged: BooleanSetting; quitoncloselastwindow: BooleanSetting; autoupdateinterval: string; // TODO: number openInProject: BooleanSetting; }; type StoreData = { lastSettings: Settings; autoUpdate: { lastCheckedForUpdate: number; }; 'autoUpdate.lastCheckedForUpdate': number; }; const store = new Store(); export default store; ================================================ FILE: packages/electron/src/main/lib/which.ts ================================================ import { execSync } from 'child_process'; import { shellEnv } from '@vvim/nvim'; /** * Checks if command exists in shell. */ const which = (command: string): string | null => { let result: string | null | undefined; try { result = execSync(`which ${command}`, { encoding: 'utf-8', env: shellEnv(), }); } catch (e) { result = null; } return result; }; export default which; ================================================ FILE: packages/electron/src/main/menu.ts ================================================ import { Menu, MenuItemConstructorOptions } from 'electron'; // import { handleCloseWindow } from 'src/main/nvim/features/closeWindow'; import { copyMenuItem, pasteMenuItem, selectAllMenuItem } from 'src/main/nvim/features/copyPaste'; import { zoomInMenuItem, zoomOutMenuItem, actualSizeMenuItem } from 'src/main/nvim/features/zoom'; import { closeWindowMenuItem } from 'src/main/nvim/features/closeWindow'; import { toggleFullScreenMenuItem } from 'src/main/nvim/features/windowSize'; let menu: Menu; const createMenu = ({ createWindow, openFile, installCli, }: { createWindow: () => void; openFile: MenuItemConstructorOptions['click']; installCli: MenuItemConstructorOptions['click']; }): void => { const menuTemplate: MenuItemConstructorOptions[] = [ { label: 'VV', submenu: [ { role: 'about' }, { label: 'Command Line Launcher...', click: installCli, }, { type: 'separator' }, { role: 'services', submenu: [] }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], }, { label: 'File', submenu: [ { label: 'New Window', accelerator: 'CmdOrCtrl+N', click: () => createWindow(), }, { label: 'Open...', accelerator: 'CmdOrCtrl+O', click: openFile, }, { role: 'recentDocuments', submenu: [ { role: 'clearRecentDocuments', }, ], }, { type: 'separator' }, { label: 'Close', accelerator: 'CmdOrCtrl+W', click: closeWindowMenuItem, }, ], }, { label: 'Edit', submenu: [ { label: 'Copy', accelerator: 'CmdOrCtrl+C', click: copyMenuItem, }, { label: 'Paste', accelerator: 'CmdOrCtrl+V', click: pasteMenuItem, }, { label: 'Select All', accelerator: 'CmdOrCtrl+A', click: selectAllMenuItem, }, ], }, { label: 'View', submenu: [ { label: 'Toggle Full Screen', accelerator: 'Cmd+Ctrl+F', click: toggleFullScreenMenuItem, }, { label: 'Actual Size', id: 'actualSize', accelerator: 'CmdOrCtrl+0', click: actualSizeMenuItem, enabled: false, }, { label: 'Zoom In', accelerator: 'CmdOrCtrl+=', click: zoomInMenuItem, }, { label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', click: zoomOutMenuItem, }, { type: 'separator' }, { label: 'Developer', submenu: [{ role: 'toggleDevTools' }], }, ], }, { role: 'window', submenu: [{ role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, { role: 'front' }], }, ]; menu = Menu.buildFromTemplate(menuTemplate); Menu.setApplicationMenu(menu); }; export default createMenu; ================================================ FILE: packages/electron/src/main/nvim/__tests__/nvim.test.ts ================================================ // eslint-disable-next-line import initNvim from 'src/main/nvim/nvim'; describe('initNvim', () => { test.todo('TODO'); }); ================================================ FILE: packages/electron/src/main/nvim/features/__tests__/backrdoundColor.test.ts ================================================ import backgroundColor from 'src/main/nvim/features/backrdoundColor'; import type { Transport } from '@vvim/nvim'; import type { BrowserWindow } from 'electron'; describe('backrdoundColor', () => { const setBackgroundColor = jest.fn(); let emitSetBackgroundColor: (color: string) => void; const transport = ({ on: (event: string, callback: (...args: any[]) => void) => { if (event === 'set-background-color') { emitSetBackgroundColor = callback; } }, } as unknown) as Transport; const win = ({ setBackgroundColor, } as unknown) as BrowserWindow; test('set window background color on `set-backround-color` event', () => { backgroundColor({ transport, win }); emitSetBackgroundColor('red'); expect(setBackgroundColor).toHaveBeenCalledWith('red'); }); }); ================================================ FILE: packages/electron/src/main/nvim/features/__tests__/windowSize.test.ts ================================================ import initWindowSize from 'src/main/nvim/features/windowSize'; import { EventEmitter } from 'events'; import type { Transport } from '@vvim/nvim'; import type { BrowserWindow } from 'electron'; describe('initWindowSize', () => { describe('set-screen-width', () => { const setContentSize = jest.fn(); const getContentSize = jest.fn(); const send = jest.fn(); // TODO: Come up with the better way to mock BrowserWindow const win = ({ setContentSize, getContentSize, getBounds: () => { /* empty */ }, setBounds: () => { /* empty */ }, isFullScreen: () => false, setSimpleFullScreen: () => { /* empty */ }, webContents: { focus: () => { /* empty */ }, }, } as unknown) as BrowserWindow; let transport: Transport; beforeEach(() => { jest.clearAllMocks(); getContentSize.mockReturnValue([100, 200]); transport = Object.assign(new EventEmitter(), { send, }); }); test('set window size on set-screen-width', () => { initWindowSize({ transport, win }); transport.emit('set-screen-width', 150); expect(setContentSize).toHaveBeenCalledWith(150, 200); }); test('set window size on set-screen-height', () => { initWindowSize({ transport, win }); getContentSize.mockReturnValueOnce([100, 200]).mockReturnValueOnce([100, 250]); transport.emit('set-screen-height', 250); expect(setContentSize).toHaveBeenCalledWith(100, 250); expect(send).not.toHaveBeenCalledWith('force-resize'); }); test('send force-resize if window height is the same after resize', () => { initWindowSize({ transport, win }); getContentSize.mockReturnValueOnce([100, 200]).mockReturnValueOnce([100, 200]); transport.emit('set-screen-height', 250); expect(send).toHaveBeenCalledWith('force-resize'); }); }); }); ================================================ FILE: packages/electron/src/main/nvim/features/backrdoundColor.ts ================================================ import type { BrowserWindow } from 'electron'; import type { Transport } from '@vvim/nvim'; /** * Change Electron window background color depending when renderer ask for it. */ const backroundColor = ({ transport, win }: { transport: Transport; win: BrowserWindow }): void => { transport.on('set-background-color', (bgColor: string) => { win.setBackgroundColor(bgColor); }); }; export default backroundColor; ================================================ FILE: packages/electron/src/main/nvim/features/closeWindow.ts ================================================ import { MenuItemConstructorOptions } from 'electron'; import { getNvimByWindow } from 'src/main/nvim/nvimByWindow'; export const closeWindowMenuItem: MenuItemConstructorOptions['click'] = async (_item, win) => { if (win) { const nvim = getNvimByWindow(win); if (nvim) { const isNotLastWindow = await nvim.eval('tabpagenr("$") > 1 || winnr("$") > 1'); if (isNotLastWindow) { nvim.command(`q`); } else { win.close(); } } } }; ================================================ FILE: packages/electron/src/main/nvim/features/copyPaste.ts ================================================ import { clipboard, MenuItemConstructorOptions } from 'electron'; import { getNvimByWindow } from 'src/main/nvim/nvimByWindow'; export const pasteMenuItem: MenuItemConstructorOptions['click'] = async (_item, win) => { const nvim = getNvimByWindow(win); if (nvim) { const clipboardText = clipboard.readText(); nvim.paste(clipboardText, true, -1); } }; export const copyMenuItem: MenuItemConstructorOptions['click'] = async (_item, win) => { const nvim = getNvimByWindow(win); if (nvim) { const mode = await nvim.getShortMode(); if (mode === 'v' || mode === 'V') { nvim.input('"*y'); } } }; export const selectAllMenuItem: MenuItemConstructorOptions['click'] = (_item, win) => { const nvim = getNvimByWindow(win); if (nvim) { nvim.input('ggVG'); } }; ================================================ FILE: packages/electron/src/main/nvim/features/focusAutocmd.ts ================================================ import { BrowserWindow } from 'electron'; import type Nvim from '@vvim/nvim'; /** * Emit FocusGained or FocusLost autocmd when app window get or loose focus. * https://neovim.io/doc/user/autocmd.html#FocusGained */ const focusAutocmd = ({ win, nvim }: { win: BrowserWindow; nvim: Nvim }): void => { win.on('focus', () => { nvim.command('doautocmd FocusGained'); }); win.on('blur', () => { nvim.command('doautocmd FocusLost'); }); }; export default focusAutocmd; ================================================ FILE: packages/electron/src/main/nvim/features/quit.ts ================================================ /** * Handle close window routine */ import { dialog, app, BrowserWindow } from 'electron'; import { deleteNvimByWindow } from 'src/main/nvim/nvimByWindow'; import type Nvim from '@vvim/nvim'; /** * If we want to quit app after closing window, shouldQuit is true. * This function is used in 'before-quit' event to switch to close app mode. */ let shouldQuit = false; export const setShouldQuit = (newShouldQuit: boolean): void => { shouldQuit = newShouldQuit; }; /** * Show Save All dialog if there are any unsaved buffers. * Cancel quit on cancel. */ const showCloseDialog = async ({ nvim, win }: { nvim: Nvim; win: BrowserWindow }) => { const unsavedBuffers = await nvim.callFunction>('VVunsavedBuffers', []); if (unsavedBuffers.length === 0) { nvim.command('qa'); } else { win.focus(); const { response } = await dialog.showMessageBox(win, { message: `You have ${unsavedBuffers.length} unsaved buffers. Do you want to save them?`, detail: `${unsavedBuffers.map((b) => b.name).join('\n')}\n`, cancelId: 2, defaultId: 0, buttons: ['Save All', 'Discard All', 'Cancel'], }); if (response === 0) { await nvim.command('xa'); // Save All } else if (response === 1) { await nvim.command('qa!'); // Discard All } setShouldQuit(false); } }; const initQuit = ({ win, nvim }: { nvim: Nvim; win: BrowserWindow }): void => { let isConnected = true; // Close window if nvim process is closed. nvim.on('close', () => { // Disable fullscreen before close, otherwise it it will keep menu bar hidden after window // is closed. win.hide(); win.setSimpleFullScreen(false); isConnected = false; deleteNvimByWindow(win); win.close(); }); // If nvim process is not closed, show Save All dialog. win.on('close', (e: Electron.Event) => { if (isConnected) { e.preventDefault(); showCloseDialog({ win, nvim }); } }); // After window is closed, continue quit app if this is a part of quit app routine win.on('closed', () => { if (shouldQuit) { app.quit(); } }); }; export default initQuit; ================================================ FILE: packages/electron/src/main/nvim/features/reloadChanged.ts ================================================ import { dialog, BrowserWindow } from 'electron'; import type Nvim from '@vvim/nvim'; /** * Show dialog when opened files are changed externally. For example, when you switch git branches. It * will prompt you to keep your changes or reload the file. * Controlled by `:VVset reloadchanged` setting, off by default. * * Deprecated because this could be done via plugins and it was buggy anyway. * If you miss this feature, you can use https://github.com/igorgladkoborodov/load-all.vim plugin, that * does the same. * * TODO: remove on the next major version * * @deprecated */ const initReloadChanged = ({ nvim, win }: { nvim: Nvim; win: BrowserWindow }): void => { type Buffer = { bufnr: string; name: string; }; let changedBuffers: Record = {}; let enabled = false; let checking = false; const showChangedDialog = async () => { if (win.isFocused() && Object.keys(changedBuffers).length > 0) { const message = Object.keys(changedBuffers).length > 1 ? `${ Object.keys(changedBuffers).length } opened files were changed outside. Do you want to reload them or keep your version?` : 'File was changed outside. Do you want to reload it or keep your version?'; const buttons = Object.keys(changedBuffers).length > 1 ? ['Reload All', 'Keep All'] : ['Reload', 'Keep']; const { response } = await dialog.showMessageBox(win, { message, detail: `${Object.keys(changedBuffers) .map((k) => changedBuffers[k].name) .join('\n')}\n`, cancelId: 1, defaultId: 0, buttons, }); if (response === 0) { nvim.callFunction( 'VVrefresh', Object.keys(changedBuffers).map((k) => changedBuffers[k].bufnr), ); changedBuffers = {}; } } }; const checktime = async () => { if (!checking) { checking = true; await nvim.command('checktime'); checking = false; showChangedDialog(); } }; const enable = (newEnabled = true) => { if (enabled !== !!newEnabled) { enabled = !!newEnabled; nvim.callFunction('VVenableReloadChanged', [enabled ? 1 : 0]); } }; nvim.on('vv:file_changed', ([buffer]: [Buffer]) => { if (enabled) { if (!changedBuffers[buffer.bufnr]) { changedBuffers[buffer.bufnr] = buffer; } checktime(); } }); nvim.on('vv:set', ([option, isEnabled]: [string, boolean]) => { if (option === 'reloadchanged') { enable(isEnabled); } }); win.on('focus', () => { if (enabled) { // The page will be blank on focus without timeout. setTimeout(() => checktime(), 10); } }); }; export default initReloadChanged; ================================================ FILE: packages/electron/src/main/nvim/features/windowSize.ts ================================================ import { screen, MenuItemConstructorOptions, BrowserWindow } from 'electron'; import type { Transport } from '@vvim/nvim'; import { getSettings, onChangeSettings, SettingsCallback } from 'src/main/nvim/settings'; import { getNvimByWindow } from 'src/main/nvim/nvimByWindow'; export const toggleFullScreenMenuItem: MenuItemConstructorOptions['click'] = (_item, win) => { const nvim = getNvimByWindow(win); if (nvim) { nvim.command('VVset fullscreen!'); } }; const initWindowSize = ({ transport, win }: { transport: Transport; win: BrowserWindow }): void => { const initialBounds = win.getBounds(); let bounds = win.getBounds(); let simpleFullScreen = false; let fullScreen = false; let isInitial = false; const set = { windowwidth: (w?: string) => { if (w !== undefined) { let width = parseInt(w, 10); if (w.toString().indexOf('%') !== -1) { width = Math.round((screen.getPrimaryDisplay().workAreaSize.width * width) / 100); } bounds.width = width; } }, windowheight: (h?: string) => { if (h !== undefined) { let height = parseInt(h, 10); if (h.toString().indexOf('%') !== -1) { height = Math.round((screen.getPrimaryDisplay().workAreaSize.height * height) / 100); } bounds.height = height; } }, windowleft: (l?: string) => { if (l !== undefined) { let left = parseInt(l, 10); if (l.toString().indexOf('%') !== -1) { const displayWidth = screen.getPrimaryDisplay().workAreaSize.width; const winWidth = bounds.width; left = Math.round(((displayWidth - winWidth) * left) / 100); } bounds.x = left; } }, windowtop: (t?: string) => { if (t !== undefined) { let top = parseInt(t, 10); if (t.toString().indexOf('%') !== -1) { const displayHeight = screen.getPrimaryDisplay().workAreaSize.height; const winHeight = bounds.height; top = Math.round(((displayHeight - winHeight) * top) / 100); } bounds.y = top; } }, fullscreen: (value: string) => { fullScreen = !!parseInt(value, 10); if (fullScreen) bounds = win.getBounds(); if (simpleFullScreen) { win.setSimpleFullScreen(fullScreen); } else { win.setFullScreen(fullScreen); } win.webContents.focus(); }, simplefullscreen: (value: string) => { simpleFullScreen = !!parseInt(value, 10); if (simpleFullScreen && win.isFullScreen()) { win.setFullScreen(false); setTimeout(() => { win.setSimpleFullScreen(true); win.webContents.focus(); }, 1); } else if (!simpleFullScreen && win.isSimpleFullScreen()) { win.setSimpleFullScreen(false); setTimeout(() => { win.setFullScreen(true); win.webContents.focus(); }, 1); } win.fullScreenable = !simpleFullScreen; // eslint-disable-line no-param-reassign }, }; const updateWindowSize: SettingsCallback = (newSettings, allSettings) => { let settings = newSettings; if (!fullScreen) { bounds = win.getBounds(); } if (isInitial && allSettings.fullscreen === 0) { settings = allSettings; bounds = initialBounds; isInitial = false; } // Order is iportant. [ 'simplefullscreen', 'fullscreen', 'windowwidth', 'windowheight', 'windowleft', 'windowtop', // @ts-expect-error FIXME ].forEach((key) => settings[key] !== undefined && set[key](settings[key])); if (!fullScreen) { win.setBounds(bounds); } }; updateWindowSize(getSettings(), getSettings()); isInitial = true; onChangeSettings(win, updateWindowSize); transport.on('set-screen-width', (width: number) => { const height = win.getContentSize()[1]; win.setContentSize(width, height); }); transport.on('set-screen-height', (height: number) => { const [width, oldHeight] = win.getContentSize(); win.setContentSize(width, height); // The new height is more than screen height. if (win.getContentSize()[1] === oldHeight) { transport.send('force-resize'); } }); }; export default initWindowSize; ================================================ FILE: packages/electron/src/main/nvim/features/windowTitle.ts ================================================ import fs from 'fs'; import { BrowserWindow } from 'electron'; import type { Nvim } from '@vvim/nvim'; const initWindowTitle = ({ nvim, win }: { win: BrowserWindow; nvim: Nvim }): void => { nvim.on('redraw', (args) => { args.forEach((arg) => { if (arg[0] === 'set_title') { win.setTitle(arg[1][0]); } }); }); nvim.on('vv:filename', ([filename]: [string]) => { if (fs.existsSync(filename)) { win.setRepresentedFilename(filename); } }); nvim.command('set title'); // Enable title nvim.command('set titlestring&'); // Set default titlestring // Send current file name to client on buffer enter nvim.command( 'autocmd BufEnter * call rpcnotify(get(g:, "vv_channel", 1), "vv:filename", expand("%:p"))', ); // Filename don't fire on startup, doing it manually nvim.command('call rpcnotify(get(g:, "vv_channel", 1), "vv:filename", expand("%:p"))'); }; export default initWindowTitle; ================================================ FILE: packages/electron/src/main/nvim/features/zoom.ts ================================================ import { app, MenuItemConstructorOptions, BrowserWindow } from 'electron'; import { getNvimByWindow } from 'src/main/nvim/nvimByWindow'; const nvimChangeZoom = (win: BrowserWindow, level: number) => { const nvim = getNvimByWindow(win); if (nvim) { nvim.command(`VVset fontsize${level > 0 ? '+' : '-'}=${Math.abs(level)}`); } }; const disableActualSizeItem = (win: BrowserWindow) => { const actualSize = app.applicationMenu?.getMenuItemById('actualSize'); if (actualSize) { // @ts-expect-error TODO: window custom params actualSize.enabled = win.zoomLevel !== 0; } }; export const zoomInMenuItem: MenuItemConstructorOptions['click'] = (_item, win) => { if (win) { // @ts-expect-error TODO: window custom params win.zoomLevel += 1; // eslint-disable-line no-param-reassign nvimChangeZoom(win, 1); disableActualSizeItem(win); } }; export const zoomOutMenuItem: MenuItemConstructorOptions['click'] = (_item, win) => { if (win) { // @ts-expect-error TODO: window custom params win.zoomLevel -= 1; // eslint-disable-line no-param-reassign nvimChangeZoom(win, -1); disableActualSizeItem(win); } }; export const actualSizeMenuItem: MenuItemConstructorOptions['click'] = (_item, win) => { if (win) { // @ts-expect-error TODO: window custom params nvimChangeZoom(win, -win.zoomLevel); // @ts-expect-error TODO: window custom params win.zoomLevel = 0; // eslint-disable-line no-param-reassign disableActualSizeItem(win); } }; const initZoom = ({ win }: { win: BrowserWindow }): void => { win.on('focus', () => { disableActualSizeItem(win); }); }; export default initZoom; ================================================ FILE: packages/electron/src/main/nvim/nvim.ts ================================================ import { app } from 'electron'; import Nvim, { startNvimProcess, ProcNvimTransport, Transport } from '@vvim/nvim'; import { setNvimByWindow } from 'src/main/nvim/nvimByWindow'; import quit from 'src/main/nvim/features/quit'; import windowTitle from 'src/main/nvim/features/windowTitle'; import zoom from 'src/main/nvim/features/zoom'; import reloadChanged from 'src/main/nvim/features/reloadChanged'; import windowSize from 'src/main/nvim/features/windowSize'; import focusAutocmd from 'src/main/nvim/features/focusAutocmd'; import backgroundColor from 'src/main/nvim/features/backrdoundColor'; import initSettings from 'src/main/nvim/settings'; import type { BrowserWindow } from 'electron'; const initNvim = ({ args, cwd, win, transport, }: { args: string[]; cwd: string; win: BrowserWindow; transport: Transport; }): void => { const proc = startNvimProcess({ args, cwd, appPath: app.getAppPath() }); const nvimTransport = new ProcNvimTransport(proc, transport); const nvim = new Nvim(nvimTransport); setNvimByWindow(win, nvim); initSettings({ win, nvim, args, transport }); windowSize({ win, transport }); quit({ win, nvim }); windowTitle({ win, nvim }); zoom({ win }); reloadChanged({ win, nvim }); focusAutocmd({ win, nvim }); backgroundColor({ win, transport }); nvim .request('nvim_get_api_info') .then(([channelId]: [string]) => nvim.setVar('vv_channel', channelId)); nvim.on('vv:vim_enter', () => { win.show(); }); }; export default initNvim; ================================================ FILE: packages/electron/src/main/nvim/nvimByWindow.ts ================================================ import type { BrowserWindow } from 'electron'; import type Nvim from '@vvim/nvim'; const nvimByWindowId: Record = []; export const getNvimByWindow = (winOrId?: number | BrowserWindow): Nvim | null => { if (!winOrId) { return null; } if (typeof winOrId === 'number') { return nvimByWindowId[winOrId]; } if (winOrId.webContents) { return nvimByWindowId[winOrId.id]; } return null; }; export const setNvimByWindow = (win: BrowserWindow, nvim: Nvim): void => { if (win.webContents) { nvimByWindowId[win.id] = nvim; } }; export const deleteNvimByWindow = (win: BrowserWindow): void => { if (win.webContents) { delete nvimByWindowId[win.id]; } }; ================================================ FILE: packages/electron/src/main/nvim/settings.ts ================================================ import debounce from 'lodash/debounce'; import type { BrowserWindow } from 'electron'; import type { Nvim, Transport } from '@vvim/nvim'; import store, { Settings } from 'src/main/lib/store'; export type SettingsCallback = (newSettings: Partial, allSettings: Settings) => void; const getDefaultSettings = (): Settings => ({ fullscreen: 0, simplefullscreen: 1, bold: 1, italic: 1, underline: 1, undercurl: 1, strikethrough: 1, fontfamily: 'monospace', fontsize: '12', lineheight: '1.25', letterspacing: '0', reloadchanged: 0, quitoncloselastwindow: 0, autoupdateinterval: '1440', // One day, 60*24 minutes openInProject: 0, }); let hasCustomConfig = false; /** * Get saved settings if we have them, default settings otherwise. * If you run app with -u flag, return default settings. */ export const getSettings = (): Settings => { if (hasCustomConfig) { return getDefaultSettings(); } return { ...getDefaultSettings(), ...store.get('lastSettings'), }; }; const onChangeSettingsCallbacks: Record = {}; export const onChangeSettings = (win: BrowserWindow, callback: SettingsCallback): void => { if (!onChangeSettingsCallbacks[win.id]) { onChangeSettingsCallbacks[win.id] = []; } onChangeSettingsCallbacks[win.id].push(callback); }; const initSettings = ({ win, nvim, args, transport, }: { win: BrowserWindow; nvim: Nvim; args: string[]; transport: Transport; }): void => { hasCustomConfig = args.indexOf('-u') !== -1; let initialSettings: Settings | null = getSettings(); let settings = getDefaultSettings(); let newSettings: Partial = {}; const applyAllSettings = async () => { settings = { ...settings, ...newSettings, }; // If we have initial settings newSetting will be only those that different from initialSettings. We // aleady applied initialSettings when we created a window. // Also store default colors to settings to avoid blinks on init. if (initialSettings && !hasCustomConfig) { newSettings = Object.keys(settings).reduce>((result, key) => { // @ts-expect-error TODO FIXME if (initialSettings[key] !== settings[key]) { return { ...result, // @ts-expect-error TODO FIXME [key]: settings[key], }; } return result; }, {}); initialSettings = null; } store.set('lastSettings', settings); transport.send('updateSettings', newSettings, settings); if (onChangeSettingsCallbacks[win.id]) { onChangeSettingsCallbacks[win.id].forEach((c) => c(newSettings, settings)); } newSettings = {}; }; const debouncedApplyAllSettings = debounce(applyAllSettings, 10); const applySetting = ([option, props]: [K, Settings[K]]) => { if (props !== null) { newSettings[option] = props; debouncedApplyAllSettings(); } }; nvim.on('vv:set', applySetting); }; export default initSettings; ================================================ FILE: packages/electron/src/main/preload.js ================================================ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electron', { ipcRenderer: { send: (channel, ...params) => { ipcRenderer.send(channel, ...params); }, on: (channel, callback) => { ipcRenderer.on(channel, (_event, ...args) => callback(...args)); }, removeListener: ipcRenderer.removeListener, }, }); ================================================ FILE: packages/electron/src/main/transport/__tests__/ipc.test.ts ================================================ import { BrowserWindow, IpcMain } from 'electron'; import { EventEmitter } from 'events'; import IpcTransport from 'src/main/transport/ipc'; describe('main transport', () => { let ipcMain: IpcMain; const win = (Object.assign(new EventEmitter(), { id: 'winId', webContents: { send: jest.fn(), }, }) as unknown) as BrowserWindow; let transport: IpcTransport; beforeEach(() => { win.removeAllListeners(); ipcMain = new EventEmitter() as IpcMain; transport = new IpcTransport(win, ipcMain); }); describe('on', () => { const listener = jest.fn(); test('calls listener if sender.id matches window id', () => { transport.on('test-event', listener); ipcMain.emit('test-event', { type: 'test-event', sender: { id: 'winId' } }, 'arg1', 'arg2'); expect(listener).toHaveBeenCalledWith('arg1', 'arg2'); }); test('does not call listener if sender.id does not match window id', () => { transport.on('test-event', listener); ipcMain.emit( 'test-event', { type: 'test-event', sender: { id: 'otherWinId' } }, 'arg1', 'arg2', ); expect(listener).not.toHaveBeenCalled(); }); test('listener with not args', () => { transport.on('test-event', listener); ipcMain.emit('test-event', { type: 'test-event', sender: { id: 'winId' } }); expect(listener).toHaveBeenCalledWith(); }); test('removes event listener on win `closed` event', () => { transport.on('test-event', listener); jest.spyOn(ipcMain, 'removeListener'); win.emit('closed'); expect(ipcMain.removeListener).toHaveBeenCalledWith('test-event', expect.any(Function)); }); }); test('unsubscribes from ipc event if there are not subscriptions left', () => { const listener = jest.fn(); const addListenerSpy = jest.spyOn(ipcMain, 'on'); const removeListenerSpy = jest.spyOn(ipcMain, 'removeListener'); transport.on('test-event', listener); transport.off('test-event', listener); expect(removeListenerSpy).toHaveBeenCalledWith('test-event', addListenerSpy.mock.calls[0][1]); }); describe('send', () => { test('pass args to win.webContents', () => { transport.send('test-event', 'arg1', 'arg2'); expect(win.webContents.send).toHaveBeenCalledWith('test-event', 'arg1', 'arg2'); }); test('with no args', () => { transport.send('test-event'); expect(win.webContents.send).toHaveBeenCalledWith('test-event'); }); test('does not send anything if window is closed', () => { win.emit('closed'); transport.send('test-event'); expect(win.webContents.send).not.toHaveBeenCalled(); }); }); }); ================================================ FILE: packages/electron/src/main/transport/ipc.ts ================================================ import { ipcMain } from 'electron'; import { EventEmitter } from 'events'; import memoize from 'lodash/memoize'; import { Transport, Args } from '@vvim/nvim'; /** * Init transport between main and renderer to be used for main side. */ class IpcTransport extends EventEmitter implements Transport { win: Electron.BrowserWindow; closed = false; ipc: Electron.IpcMain; constructor(win: Electron.BrowserWindow, ipc = ipcMain) { super(); this.win = win; this.ipc = ipc; win.on('closed', () => { this.closed = true; }); this.on('newListener', (eventName: string) => { if ( !this.listenerCount(eventName) && !['newListener', 'removeListener'].includes(eventName) ) { this.ipc.on(eventName, this.handleEvent(eventName)); this.win.on('closed', () => { this.ipc.removeListener(eventName, this.handleEvent(eventName)); }); } }); this.on('removeListener', (eventName: string) => { if ( !this.listenerCount(eventName) && !['newListener', 'removeListener'].includes(eventName) ) { this.ipc.removeListener(eventName, this.handleEvent(eventName)); } }); } handleEvent = memoize( (eventName: string) => (event: Electron.IpcMainEvent, ...args: Args): void => { const { sender: { id }, } = event; if (id === this.win.id) { this.emit(eventName, ...args); } }, ); send(channel: string, ...args: any[]): void { if (!this.closed) { this.win.webContents.send(channel, ...args); } } } export default IpcTransport; ================================================ FILE: packages/electron/src/renderer/index.html ================================================ VV ================================================ FILE: packages/electron/src/renderer/index.ts ================================================ import renderer from '@vvim/browser-renderer'; renderer(); ================================================ FILE: packages/electron/tsconfig.json ================================================ { "extends": "../../tsconfig", "compilerOptions": { "baseUrl": "." }, "include": ["src", "@types"] } ================================================ FILE: packages/nvim/README.md ================================================ # @vvim/nvim Lightweight transport agnostic Neovim API client to be used in other @vvim packages. ================================================ FILE: packages/nvim/babel.config.json ================================================ { "extends": "../../babel.config.json", "plugins": [ [ "module-resolver", { "root": ["."], "alias": { "src": "./src" } } ] ] } ================================================ FILE: packages/nvim/config/webpack.config.js ================================================ const path = require('path'); const { merge } = require('webpack-merge'); const buildPath = path.resolve(__dirname, './../dist'); const commonConfig = { mode: 'development', output: { path: buildPath, filename: '[name].js', libraryTarget: 'umd', globalObject: 'this', }, devtool: 'eval-cheap-source-map', resolve: { extensions: ['.ts', '.js'], }, module: { rules: [ { test: /\.(js|ts)$/, exclude: /node_modules/, loader: 'babel-loader', }, ], }, }; const browserConfig = merge(commonConfig, { target: 'web', entry: { browser: './src/browser.ts', }, }); const nodeConfig = merge(commonConfig, { target: 'node', entry: { index: './src/index.ts', }, }); module.exports = [browserConfig, nodeConfig]; ================================================ FILE: packages/nvim/config/webpack.prod.config.js ================================================ const { merge } = require('webpack-merge'); const webpackConfig = require('./webpack.config'); const prod = { mode: 'production', devtool: 'source-map', }; const webpackConfigProd = webpackConfig.map((config) => merge(config, prod)); module.exports = webpackConfigProd; ================================================ FILE: packages/nvim/jest.config.js ================================================ module.exports = { clearMocks: true, moduleNameMapper: { 'src/(.*)': ['/src/$1'], }, testPathIgnorePatterns: ['/node_modules/', '/dist/'], }; ================================================ FILE: packages/nvim/package.json ================================================ { "name": "@vvim/nvim", "version": "0.0.1", "description": "Lightweight transport agnostic Neovim API client to be used in other @vvim packages", "author": "Igor Gladkoborodov ", "keywords": [ "vim", "neovim", "client", "api" ], "homepage": "https://github.com/vv-vim/vv#readme", "license": "MIT", "main": "dist/index.js", "browser": "dist/browser.js", "sideEffects": false, "scripts": { "test": "jest", "clean": "rm -rf dist/*", "build:types": "tsc -p tsconfig.declaration.json", "build:dev": "webpack --config ./config/webpack.config.js", "build:prod": "webpack --config ./config/webpack.prod.config.js", "build": "npm-run-all clean build:types build:prod", "dev": "npm-run-all --parallel \"build:types --watch\" \"build:dev --watch\"" }, "publishConfig": { "registry": "https://registry.yarnpkg.com" }, "repository": { "type": "git", "url": "git+https://github.com/vv-vim/vv.git" }, "bugs": { "url": "https://github.com/vv-vim/vv/issues" }, "browserslist": [ "defaults", "last 2 electron versions", "maintained node versions" ], "devDependencies": { "@types/express": "^4.17.11", "@types/lodash": "^4.14.168", "@types/msgpack-lite": "^0.1.7", "@types/node": "^14.14.31", "@types/ws": "^7.4.0", "strict-event-emitter-types": "^2.0.0" }, "dependencies": { "lodash": "^4.17.21", "msgpack-lite": "^0.1.26", "ws": "^7.4.6" } } ================================================ FILE: packages/nvim/src/Nvim.ts ================================================ import { EventEmitter } from 'events'; import { nvimCommandNames } from 'src/__generated__/constants'; import type { Transport, MessageType, NvimInterface } from './types'; const NvimEventEmitter = (EventEmitter as unknown) as { new (): NvimInterface }; /** * Lightweight transport agnostic Neovim API client to be used in other @vvim packages. */ class Nvim extends NvimEventEmitter { private requestId = 0; private transport: Transport; private requestPromises: Record< string, { resolve: (result: any) => void; reject: (error: any) => void } > = {}; private isRenderer: boolean; constructor(transport: Transport, isRenderer = false) { super(); this.transport = transport; this.isRenderer = isRenderer; this.transport.on('nvim:data', (params: MessageType) => { if (params[0] === 0) { // eslint-disable-next-line no-console console.error('Unsupported request type', ...params); } else if (params[0] === 1) { this.handleResponse(params[1], params[2], params[3]); } else if (params[0] === 2) { this.emit(params[1], params[2]); } }); this.transport.on('nvim:close', () => { this.emit('close'); }); (Object.keys(nvimCommandNames) as Array).forEach( (commandName) => { (this as any)[commandName] = (...params: any[]) => this.request(nvimCommandNames[commandName], params); }, ); this.on('newListener', (eventName: string) => { if ( !this.listenerCount(eventName) && !['close', 'newListener', 'removeListener'].includes(eventName) && !eventName.startsWith('nvim:') ) { this.subscribe(eventName); } }); this.on('removeListener', (eventName: string) => { if ( !this.listenerCount(eventName) && !['close', 'newListener', 'removeListener'].includes(eventName) && !eventName.startsWith('nvim:') ) { this.unsubscribe(eventName); } }); } request(command: string, params: any[] = []): Promise { this.requestId += 1; // Workaround to avoid request ids conflict vetween main and renderer. Renderer ids are even, main ids are odd. // TODO: sync request id between all instances. const id = this.requestId * 2 + (this.isRenderer ? 0 : 1); this.transport.send('nvim:write', id, command, params); return new Promise((resolve, reject) => { this.requestPromises[id] = { resolve, reject, }; }); } private handleResponse(id: number, error: Error, result?: any): void { if (this.requestPromises[id]) { if (error) { this.requestPromises[id].reject(error); } else { this.requestPromises[id].resolve(result); } delete this.requestPromises[id]; } } /** * Fetch current mode from nvim, leaves only first letter to match groups of modes. * https://neovim.io/doc/user/eval.html#mode() */ getShortMode = async (): Promise => { const { mode } = await this.getMode(); return mode.replace('CTRL-', '')[0]; }; } export default Nvim; ================================================ FILE: packages/nvim/src/ProcNvimTransport.ts ================================================ import { createDecodeStream, encode } from 'msgpack-lite'; import { EventEmitter } from 'events'; import type { ChildProcessWithoutNullStreams } from 'child_process'; import type { DecodeStream } from 'msgpack-lite'; import type { Transport, MessageType } from 'src/types'; /** * Transport that communicates directly with nvim process. * It also used as to communicate nvim api with remote transport. */ class ProcNvimTransport extends EventEmitter implements Transport { private msgpackIn: DecodeStream; private proc: ChildProcessWithoutNullStreams; constructor(proc: ChildProcessWithoutNullStreams, remoteTransport?: Transport) { super(); this.proc = proc; const decodeStream = createDecodeStream(); this.msgpackIn = this.proc.stdout.pipe(decodeStream); this.proc.on('close', () => this.emit('nvim:close')); this.msgpackIn.on('data', (message: MessageType) => this.emit('nvim:data', message)); if (remoteTransport) { this.attachRemoteTransport(remoteTransport); } } attachRemoteTransport(remoteTransport: Transport): void { remoteTransport.on('nvim:write', (...args: [number, string, string[]]) => this.write(...args)); this.on('nvim:close', () => remoteTransport.send('nvim:close')); this.on('nvim:data', (data: MessageType) => remoteTransport.send('nvim:data', data)); } private write(id: number, command: string, params: string[]): void { if (this.proc.stdin.writable) { this.proc.stdin.write(encode([0, id, command, params])); } } send(channel: string, id: number, command: string, params: string[]): void { if (channel === 'nvim:write') { this.write(id, command, params); } } } export default ProcNvimTransport; ================================================ FILE: packages/nvim/src/__generated__/constants.ts ================================================ /* eslint-disable camelcase */ /** * Constants generated by `yarn generate-types`. Do not edit manually. * * Version: 0.5.0 * Api Level: 7 * Api Compatible: 0 * Api Prerelease: false */ export const nvimCommandNames = { bufLineCount: 'nvim_buf_line_count', bufAttach: 'nvim_buf_attach', bufDetach: 'nvim_buf_detach', bufGetLines: 'nvim_buf_get_lines', bufSetLines: 'nvim_buf_set_lines', bufSetText: 'nvim_buf_set_text', bufGetOffset: 'nvim_buf_get_offset', bufGetVar: 'nvim_buf_get_var', bufGetChangedtick: 'nvim_buf_get_changedtick', bufGetKeymap: 'nvim_buf_get_keymap', bufSetKeymap: 'nvim_buf_set_keymap', bufDelKeymap: 'nvim_buf_del_keymap', bufGetCommands: 'nvim_buf_get_commands', bufSetVar: 'nvim_buf_set_var', bufDelVar: 'nvim_buf_del_var', bufGetOption: 'nvim_buf_get_option', bufSetOption: 'nvim_buf_set_option', bufGetName: 'nvim_buf_get_name', bufSetName: 'nvim_buf_set_name', bufIsLoaded: 'nvim_buf_is_loaded', bufDelete: 'nvim_buf_delete', bufIsValid: 'nvim_buf_is_valid', bufGetMark: 'nvim_buf_get_mark', bufGetExtmarkById: 'nvim_buf_get_extmark_by_id', bufGetExtmarks: 'nvim_buf_get_extmarks', bufSetExtmark: 'nvim_buf_set_extmark', bufDelExtmark: 'nvim_buf_del_extmark', bufAddHighlight: 'nvim_buf_add_highlight', bufClearNamespace: 'nvim_buf_clear_namespace', bufSetVirtualText: 'nvim_buf_set_virtual_text', bufCall: 'nvim_buf_call', tabpageListWins: 'nvim_tabpage_list_wins', tabpageGetVar: 'nvim_tabpage_get_var', tabpageSetVar: 'nvim_tabpage_set_var', tabpageDelVar: 'nvim_tabpage_del_var', tabpageGetWin: 'nvim_tabpage_get_win', tabpageGetNumber: 'nvim_tabpage_get_number', tabpageIsValid: 'nvim_tabpage_is_valid', uiAttach: 'nvim_ui_attach', uiDetach: 'nvim_ui_detach', uiTryResize: 'nvim_ui_try_resize', uiSetOption: 'nvim_ui_set_option', uiTryResizeGrid: 'nvim_ui_try_resize_grid', uiPumSetHeight: 'nvim_ui_pum_set_height', uiPumSetBounds: 'nvim_ui_pum_set_bounds', exec: 'nvim_exec', command: 'nvim_command', getHlByName: 'nvim_get_hl_by_name', getHlById: 'nvim_get_hl_by_id', getHlIdByName: 'nvim_get_hl_id_by_name', setHl: 'nvim_set_hl', feedkeys: 'nvim_feedkeys', input: 'nvim_input', inputMouse: 'nvim_input_mouse', replaceTermcodes: 'nvim_replace_termcodes', eval: 'nvim_eval', execLua: 'nvim_exec_lua', notify: 'nvim_notify', callFunction: 'nvim_call_function', callDictFunction: 'nvim_call_dict_function', strwidth: 'nvim_strwidth', listRuntimePaths: 'nvim_list_runtime_paths', getRuntimeFile: 'nvim_get_runtime_file', setCurrentDir: 'nvim_set_current_dir', getCurrentLine: 'nvim_get_current_line', setCurrentLine: 'nvim_set_current_line', delCurrentLine: 'nvim_del_current_line', getVar: 'nvim_get_var', setVar: 'nvim_set_var', delVar: 'nvim_del_var', getVvar: 'nvim_get_vvar', setVvar: 'nvim_set_vvar', getOption: 'nvim_get_option', getAllOptionsInfo: 'nvim_get_all_options_info', getOptionInfo: 'nvim_get_option_info', setOption: 'nvim_set_option', echo: 'nvim_echo', outWrite: 'nvim_out_write', errWrite: 'nvim_err_write', errWriteln: 'nvim_err_writeln', listBufs: 'nvim_list_bufs', getCurrentBuf: 'nvim_get_current_buf', setCurrentBuf: 'nvim_set_current_buf', listWins: 'nvim_list_wins', getCurrentWin: 'nvim_get_current_win', setCurrentWin: 'nvim_set_current_win', createBuf: 'nvim_create_buf', openTerm: 'nvim_open_term', chanSend: 'nvim_chan_send', openWin: 'nvim_open_win', listTabpages: 'nvim_list_tabpages', getCurrentTabpage: 'nvim_get_current_tabpage', setCurrentTabpage: 'nvim_set_current_tabpage', createNamespace: 'nvim_create_namespace', getNamespaces: 'nvim_get_namespaces', paste: 'nvim_paste', put: 'nvim_put', subscribe: 'nvim_subscribe', unsubscribe: 'nvim_unsubscribe', getColorByName: 'nvim_get_color_by_name', getColorMap: 'nvim_get_color_map', getContext: 'nvim_get_context', loadContext: 'nvim_load_context', getMode: 'nvim_get_mode', getKeymap: 'nvim_get_keymap', setKeymap: 'nvim_set_keymap', delKeymap: 'nvim_del_keymap', getCommands: 'nvim_get_commands', getApiInfo: 'nvim_get_api_info', setClientInfo: 'nvim_set_client_info', getChanInfo: 'nvim_get_chan_info', listChans: 'nvim_list_chans', callAtomic: 'nvim_call_atomic', parseExpression: 'nvim_parse_expression', listUis: 'nvim_list_uis', getProcChildren: 'nvim_get_proc_children', getProc: 'nvim_get_proc', selectPopupmenuItem: 'nvim_select_popupmenu_item', setDecorationProvider: 'nvim_set_decoration_provider', winGetBuf: 'nvim_win_get_buf', winSetBuf: 'nvim_win_set_buf', winGetCursor: 'nvim_win_get_cursor', winSetCursor: 'nvim_win_set_cursor', winGetHeight: 'nvim_win_get_height', winSetHeight: 'nvim_win_set_height', winGetWidth: 'nvim_win_get_width', winSetWidth: 'nvim_win_set_width', winGetVar: 'nvim_win_get_var', winSetVar: 'nvim_win_set_var', winDelVar: 'nvim_win_del_var', winGetOption: 'nvim_win_get_option', winSetOption: 'nvim_win_set_option', winGetPosition: 'nvim_win_get_position', winGetTabpage: 'nvim_win_get_tabpage', winGetNumber: 'nvim_win_get_number', winIsValid: 'nvim_win_is_valid', winSetConfig: 'nvim_win_set_config', winGetConfig: 'nvim_win_get_config', winHide: 'nvim_win_hide', winClose: 'nvim_win_close', winCall: 'nvim_win_call', } as const; ================================================ FILE: packages/nvim/src/__generated__/types.ts ================================================ /* eslint-disable camelcase */ /** * Types generated by `yarn generate-types`. Do not edit manually. * * Version: 0.5.0 * Api Level: 7 * Api Compatible: 0 * Api Prerelease: false */ /** * UI events types emitted by `redraw` event. Do not edit manually. * More info: https://neovim.io/doc/user/ui.html */ export type UiEvents = { mode_info_set: [enabled: boolean, cursor_styles: Array]; update_menu: []; busy_start: []; busy_stop: []; mouse_on: []; mouse_off: []; mode_change: [mode: string, mode_idx: number]; bell: []; visual_bell: []; flush: []; suspend: []; set_title: [title: string]; set_icon: [icon: string]; screenshot: [path: string]; option_set: [name: string, value: any]; update_fg: [fg: number]; update_bg: [bg: number]; update_sp: [sp: number]; resize: [width: number, height: number]; clear: []; eol_clear: []; cursor_goto: [row: number, col: number]; highlight_set: [attrs: Record]; put: [str: string]; set_scroll_region: [top: number, bot: number, left: number, right: number]; scroll: [count: number]; default_colors_set: [ rgb_fg: number, rgb_bg: number, rgb_sp: number, cterm_fg: number, cterm_bg: number, ]; hl_attr_define: [ id: number, rgb_attrs: Record, cterm_attrs: Record, info: Array, ]; hl_group_set: [name: string, id: number]; grid_resize: [grid: number, width: number, height: number]; grid_clear: [grid: number]; grid_cursor_goto: [grid: number, row: number, col: number]; grid_line: [grid: number, row: number, col_start: number, data: Array]; grid_scroll: [ grid: number, top: number, bot: number, left: number, right: number, rows: number, cols: number, ]; grid_destroy: [grid: number]; win_pos: [ grid: number, win: number, startrow: number, startcol: number, width: number, height: number, ]; win_float_pos: [ grid: number, win: number, anchor: string, anchor_grid: number, anchor_row: number, anchor_col: number, focusable: boolean, zindex: number, ]; win_external_pos: [grid: number, win: number]; win_hide: [grid: number]; win_close: [grid: number]; msg_set_pos: [grid: number, row: number, scrolled: boolean, sep_char: string]; win_viewport: [ grid: number, win: number, topline: number, botline: number, curline: number, curcol: number, ]; popupmenu_show: [items: Array, selected: number, row: number, col: number, grid: number]; popupmenu_hide: []; popupmenu_select: [selected: number]; tabline_update: [current: number, tabs: Array, current_buffer: number, buffers: Array]; cmdline_show: [ content: Array, pos: number, firstc: string, prompt: string, indent: number, level: number, ]; cmdline_pos: [pos: number, level: number]; cmdline_special_char: [c: string, shift: boolean, level: number]; cmdline_hide: [level: number]; cmdline_block_show: [lines: Array]; cmdline_block_append: [lines: Array]; cmdline_block_hide: []; wildmenu_show: [items: Array]; wildmenu_select: [selected: number]; wildmenu_hide: []; msg_show: [kind: string, content: Array, replace_last: boolean]; msg_clear: []; msg_showcmd: [content: Array]; msg_showmode: [content: Array]; msg_ruler: [content: Array]; msg_history_show: [entries: Array]; }; /** * Nvim commands. * More info: https://neovim.io/doc/user/api.html */ export type NvimCommands = { nvim_buf_line_count: (buffer: number) => number; nvim_buf_attach: (buffer: number, send_buffer: boolean, opts: Record) => boolean; nvim_buf_detach: (buffer: number) => boolean; nvim_buf_get_lines: ( buffer: number, start: number, end: number, strict_indexing: boolean, ) => string[]; nvim_buf_set_lines: ( buffer: number, start: number, end: number, strict_indexing: boolean, replacement: string[], ) => void; nvim_buf_set_text: ( buffer: number, start_row: number, start_col: number, end_row: number, end_col: number, replacement: string[], ) => void; nvim_buf_get_offset: (buffer: number, index: number) => number; nvim_buf_get_var: (buffer: number, name: string) => any; nvim_buf_get_changedtick: (buffer: number) => number; nvim_buf_get_keymap: (buffer: number, mode: string) => Record[]; nvim_buf_set_keymap: ( buffer: number, mode: string, lhs: string, rhs: string, opts: Record, ) => void; nvim_buf_del_keymap: (buffer: number, mode: string, lhs: string) => void; nvim_buf_get_commands: (buffer: number, opts: Record) => Record; nvim_buf_set_var: (buffer: number, name: string, value: any) => void; nvim_buf_del_var: (buffer: number, name: string) => void; nvim_buf_get_option: (buffer: number, name: string) => any; nvim_buf_set_option: (buffer: number, name: string, value: any) => void; nvim_buf_get_name: (buffer: number) => string; nvim_buf_set_name: (buffer: number, name: string) => void; nvim_buf_is_loaded: (buffer: number) => boolean; nvim_buf_delete: (buffer: number, opts: Record) => void; nvim_buf_is_valid: (buffer: number) => boolean; nvim_buf_get_mark: (buffer: number, name: string) => [number, number]; nvim_buf_get_extmark_by_id: ( buffer: number, ns_id: number, id: number, opts: Record, ) => number[]; nvim_buf_get_extmarks: ( buffer: number, ns_id: number, start: any, end: any, opts: Record, ) => Array; nvim_buf_set_extmark: ( buffer: number, ns_id: number, line: number, col: number, opts: Record, ) => number; nvim_buf_del_extmark: (buffer: number, ns_id: number, id: number) => boolean; nvim_buf_add_highlight: ( buffer: number, ns_id: number, hl_group: string, line: number, col_start: number, col_end: number, ) => number; nvim_buf_clear_namespace: ( buffer: number, ns_id: number, line_start: number, line_end: number, ) => void; nvim_buf_set_virtual_text: ( buffer: number, src_id: number, line: number, chunks: Array, opts: Record, ) => number; nvim_buf_call: (buffer: number, fun: any) => any; nvim_tabpage_list_wins: (tabpage: number) => number[]; nvim_tabpage_get_var: (tabpage: number, name: string) => any; nvim_tabpage_set_var: (tabpage: number, name: string, value: any) => void; nvim_tabpage_del_var: (tabpage: number, name: string) => void; nvim_tabpage_get_win: (tabpage: number) => number; nvim_tabpage_get_number: (tabpage: number) => number; nvim_tabpage_is_valid: (tabpage: number) => boolean; nvim_ui_attach: (width: number, height: number, options: Record) => void; nvim_ui_detach: () => void; nvim_ui_try_resize: (width: number, height: number) => void; nvim_ui_set_option: (name: string, value: any) => void; nvim_ui_try_resize_grid: (grid: number, width: number, height: number) => void; nvim_ui_pum_set_height: (height: number) => void; nvim_ui_pum_set_bounds: (width: number, height: number, row: number, col: number) => void; nvim_exec: (src: string, output: boolean) => string; nvim_command: (command: string) => void; nvim_get_hl_by_name: (name: string, rgb: boolean) => Record; nvim_get_hl_by_id: (hl_id: number, rgb: boolean) => Record; nvim_get_hl_id_by_name: (name: string) => number; nvim_set_hl: (ns_id: number, name: string, val: Record) => void; nvim_feedkeys: (keys: string, mode: string, escape_csi: boolean) => void; nvim_input: (keys: string) => number; nvim_input_mouse: ( button: string, action: string, modifier: string, grid: number, row: number, col: number, ) => void; nvim_replace_termcodes: ( str: string, from_part: boolean, do_lt: boolean, special: boolean, ) => string; nvim_eval: (expr: string) => any; nvim_exec_lua: (code: string, args: Array) => any; nvim_notify: (msg: string, log_level: number, opts: Record) => any; nvim_call_function: (fn: string, args: Array) => any; nvim_call_dict_function: (dict: any, fn: string, args: Array) => any; nvim_strwidth: (text: string) => number; nvim_list_runtime_paths: () => string[]; nvim_get_runtime_file: (name: string, all: boolean) => string[]; nvim_set_current_dir: (dir: string) => void; nvim_get_current_line: () => string; nvim_set_current_line: (line: string) => void; nvim_del_current_line: () => void; nvim_get_var: (name: string) => any; nvim_set_var: (name: string, value: any) => void; nvim_del_var: (name: string) => void; nvim_get_vvar: (name: string) => any; nvim_set_vvar: (name: string, value: any) => void; nvim_get_option: (name: string) => any; nvim_get_all_options_info: () => Record; nvim_get_option_info: (name: string) => Record; nvim_set_option: (name: string, value: any) => void; nvim_echo: (chunks: Array, history: boolean, opts: Record) => void; nvim_out_write: (str: string) => void; nvim_err_write: (str: string) => void; nvim_err_writeln: (str: string) => void; nvim_list_bufs: () => number[]; nvim_get_current_buf: () => number; nvim_set_current_buf: (buffer: number) => void; nvim_list_wins: () => number[]; nvim_get_current_win: () => number; nvim_set_current_win: (win: number) => void; nvim_create_buf: (listed: boolean, scratch: boolean) => number; nvim_open_term: (buffer: number, opts: Record) => number; nvim_chan_send: (chan: number, data: string) => void; nvim_open_win: (buffer: number, enter: boolean, config: Record) => number; nvim_list_tabpages: () => number[]; nvim_get_current_tabpage: () => number; nvim_set_current_tabpage: (tabpage: number) => void; nvim_create_namespace: (name: string) => number; nvim_get_namespaces: () => Record; nvim_paste: (data: string, crlf: boolean, phase: number) => boolean; nvim_put: (lines: string[], type: string, after: boolean, follow: boolean) => void; nvim_subscribe: (event: string) => void; nvim_unsubscribe: (event: string) => void; nvim_get_color_by_name: (name: string) => number; nvim_get_color_map: () => Record; nvim_get_context: (opts: Record) => Record; nvim_load_context: (dict: Record) => any; nvim_get_mode: () => Record; nvim_get_keymap: (mode: string) => Record[]; nvim_set_keymap: (mode: string, lhs: string, rhs: string, opts: Record) => void; nvim_del_keymap: (mode: string, lhs: string) => void; nvim_get_commands: (opts: Record) => Record; nvim_get_api_info: () => Array; nvim_set_client_info: ( name: string, version: Record, type: string, methods: Record, attributes: Record, ) => void; nvim_get_chan_info: (chan: number) => Record; nvim_list_chans: () => Array; nvim_call_atomic: (calls: Array) => Array; nvim_parse_expression: (expr: string, flags: string, highlight: boolean) => Record; nvim_list_uis: () => Array; nvim_get_proc_children: (pid: number) => Array; nvim_get_proc: (pid: number) => any; nvim_select_popupmenu_item: ( item: number, insert: boolean, finish: boolean, opts: Record, ) => void; nvim_set_decoration_provider: (ns_id: number, opts: Record) => void; nvim_win_get_buf: (win: number) => number; nvim_win_set_buf: (win: number, buffer: number) => void; nvim_win_get_cursor: (win: number) => [number, number]; nvim_win_set_cursor: (win: number, pos: [number, number]) => void; nvim_win_get_height: (win: number) => number; nvim_win_set_height: (win: number, height: number) => void; nvim_win_get_width: (win: number) => number; nvim_win_set_width: (win: number, width: number) => void; nvim_win_get_var: (win: number, name: string) => any; nvim_win_set_var: (win: number, name: string, value: any) => void; nvim_win_del_var: (win: number, name: string) => void; nvim_win_get_option: (win: number, name: string) => any; nvim_win_set_option: (win: number, name: string, value: any) => void; nvim_win_get_position: (win: number) => [number, number]; nvim_win_get_tabpage: (win: number) => number; nvim_win_get_number: (win: number) => number; nvim_win_is_valid: (win: number) => boolean; nvim_win_set_config: (win: number, config: Record) => void; nvim_win_get_config: (win: number) => Record; nvim_win_hide: (win: number) => void; nvim_win_close: (win: number, force: boolean) => void; nvim_win_call: (win: number, fun: any) => any; }; ================================================ FILE: packages/nvim/src/__tests__/Nvim.test.ts ================================================ import { EventEmitter } from 'events'; import Nvim from 'src/nvim'; import type { Transport } from 'src/types'; describe('Nvim', () => { const send = jest.fn(); const transportMock: Transport = Object.assign(new EventEmitter(), { send, }); let nvim: Nvim; beforeEach(() => { transportMock.removeAllListeners(); nvim = new Nvim(transportMock); }); describe('request', () => { test('call send with `nvim:write` on request', () => { nvim.request('nvim_command', ['param1', 'param2']); expect(send).toHaveBeenCalledWith('nvim:write', 3, 'nvim_command', ['param1', 'param2']); }); test('increment request id on second call and it is always odd', () => { nvim.request('nvim_command1'); expect(send).toHaveBeenCalledWith('nvim:write', 3, 'nvim_command1', []); nvim.request('nvim_command2'); expect(send).toHaveBeenCalledWith('nvim:write', 5, 'nvim_command2', []); }); test('in renderer mode request id is always even', () => { nvim = new Nvim(transportMock, true); nvim.request('nvim_command1'); expect(send).toHaveBeenCalledWith('nvim:write', 2, 'nvim_command1', []); nvim.request('nvim_command2'); expect(send).toHaveBeenCalledWith('nvim:write', 4, 'nvim_command2', []); }); test('receives result of request', async () => { const resultPromise = nvim.request('nvim_command', ['param1', 'param2']); transportMock.emit('nvim:data', [1, 3, null, 'result']); expect(await resultPromise).toEqual('result'); }); test('reject on error returned', async () => { const resultPromise = nvim.request('nvim_command', ['param1', 'param2']); transportMock.emit('nvim:data', [1, 3, 'error']); await expect(resultPromise).rejects.toEqual('error'); }); }); describe('notification', () => { test('send `nvim_subscribe` when you subscribe', () => { nvim.on('onSomething', () => null); expect(send).toHaveBeenCalledWith('nvim:write', 3, 'nvim_subscribe', ['onSomething']); }); test('does not subscribe twice on the same event', () => { nvim.on('onSomething', () => null); nvim.on('onSomething', () => null); expect(send).toHaveBeenCalledWith('nvim:write', 3, 'nvim_subscribe', ['onSomething']); expect(send).toHaveBeenCalledTimes(1); }); test('send `nvim_unsubscribe` when you subscribe', () => { const listener = () => null; nvim.on('onSomething', listener); nvim.removeListener('onSomething', listener); expect(send).toHaveBeenCalledWith('nvim:write', 5, 'nvim_unsubscribe', ['onSomething']); }); test('does not unsubscribe if you have events with that name', () => { const listener = () => null; const anotherListener = () => null; nvim.on('onSomething', listener); nvim.on('onSomething', anotherListener); nvim.removeListener('onSomething', listener); expect(send).not.toHaveBeenCalledWith('nvim:write', 5, 'nvim_unsubscribe', ['onSomething']); }); test('receives notification for subscription', () => { const callback = jest.fn(); nvim.on('onSomething', callback); transportMock.emit('nvim:data', [2, 'onSomething', 'params1']); expect(callback).toHaveBeenCalledWith('params1'); transportMock.emit('nvim:data', [2, 'onSomething', 'params2']); expect(callback).toHaveBeenCalledWith('params2'); }); test('does not receives notifications that are not subscribed', () => { const callback = jest.fn(); nvim.on('onSomething', callback); transportMock.emit('nvim:data', [2, 'onSomethingElse', 'params1']); expect(callback).not.toHaveBeenCalled(); }); }); describe('request message type', () => { test('receives result of request', async () => { const errorSpy = jest.spyOn(console, 'error').mockImplementationOnce(() => { /* empty */ }); transportMock.emit('nvim:data', [0]); expect(errorSpy).toHaveBeenCalled(); }); }); describe('predefined commands', () => { const commands = [ ['subscribe', 'subscribe'], ['unsubscribe', 'unsubscribe'], ['callFunction', 'call_function'], ['command', 'command'], ['input', 'input'], ['inputMouse', 'input_mouse'], ['getMode', 'get_mode'], ['uiTryResize', 'ui_try_resize'], ['uiAttach', 'ui_attach'], ['getHlByName', 'get_hl_by_name'], ['paste', 'paste'], ] as const; commands.forEach(([command, request]) => { test(`${command}`, () => { nvim = new Nvim(transportMock); nvim[command]('param1', 'param2'); expect(send).toHaveBeenCalledWith('nvim:write', 3, `nvim_${request}`, ['param1', 'param2']); }); }); test('eval', () => { nvim = new Nvim(transportMock); nvim.eval('param1'); expect(send).toHaveBeenCalledWith('nvim:write', 3, `nvim_eval`, ['param1']); }); test('getShortMode returns mode', async () => { const resultPromise = nvim.getShortMode(); transportMock.emit('nvim:data', [1, 3, null, { mode: 'n' }]); expect(await resultPromise).toBe('n'); }); test('getShortMode cut CTRL- from mode', async () => { const resultPromise = nvim.getShortMode(); transportMock.emit('nvim:data', [1, 3, null, { mode: 'CTRL-n' }]); expect(await resultPromise).toBe('n'); }); }); test('emit `close` when transport emits `nvim:close`', () => { const callback1 = jest.fn(); const callback2 = jest.fn(); nvim.on('close', callback1); nvim.on('close', callback2); transportMock.emit('nvim:close'); expect(callback1).toHaveBeenCalled(); expect(callback2).toHaveBeenCalled(); }); }); ================================================ FILE: packages/nvim/src/__tests__/ProcNvimTransport.test.ts ================================================ import { EventEmitter } from 'events'; import { PassThrough } from 'stream'; import { encode } from 'msgpack-lite'; import type { ChildProcessWithoutNullStreams } from 'child_process'; import ProcNvimTransport from 'src/ProcNvimTransport'; describe('ProcNvimTransport', () => { let proc: ChildProcessWithoutNullStreams; let transport: ProcNvimTransport; const onData = jest.fn(); const remoteTransport = Object.assign(new EventEmitter(), { send: jest.fn(), }); beforeEach(() => { proc = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stdin: new PassThrough(), } as unknown) as ChildProcessWithoutNullStreams; proc.stdin.on('data', onData); transport = new ProcNvimTransport(proc, remoteTransport); }); test('transport receives `nvim:data` event with msgpack-encoded data from proc.stdout', () => { const readCallback = jest.fn(); transport.on('nvim:data', readCallback); proc.stdout.push(encode('hello')); expect(readCallback).toHaveBeenCalledWith('hello'); }); test('transport emits nvim:close when proc is closed', () => { const handleClose = jest.fn(); transport.on('nvim:close', handleClose); proc.emit('close'); expect(handleClose).toHaveBeenCalled(); }); test('send to `nvim:write` writes msgpack-encoded data to stdin', async () => { transport.send('nvim:write', 10, 'command', ['param1', 'param2']); expect(onData).toHaveBeenCalledWith(encode([0, 10, 'command', ['param1', 'param2']])); }); test("don't write to stdin if it is not writable", async () => { proc.stdin.end(); transport.send('nvim:write', 10, 'command', ['param1', 'param2']); expect(onData).not.toHaveBeenCalled(); }); describe('remoteTransport', () => { test('receives and relays to proc.stin `nvim-send` event from remoteTransport', () => { remoteTransport.emit('nvim:write', 1, 'command', ['params']); expect(onData).toHaveBeenCalledWith(encode([0, 1, 'command', ['params']])); }); test('send `nvim:close` event to remoteTransport on close', () => { proc.emit('close'); expect(remoteTransport.send).toHaveBeenCalledWith('nvim:close'); }); test('translate nvim proc stdout data to remoteTransport', () => { proc.stdout.push(encode('hello')); expect(remoteTransport.send).toHaveBeenCalledWith('nvim:data', 'hello'); }); test('has attachRemoteTransport method', () => { transport = new ProcNvimTransport(proc); transport.attachRemoteTransport(remoteTransport); proc.stdout.push(encode('hello')); expect(remoteTransport.send).toHaveBeenCalledWith('nvim:data', 'hello'); }); }); }); ================================================ FILE: packages/nvim/src/__tests__/process.test.ts ================================================ import { PassThrough } from 'stream'; import { spawn } from 'child_process'; import type { ChildProcessWithoutNullStreams } from 'child_process'; import startNvimProcess from 'src/process'; jest.mock('child_process'); const mockedSpawn = jest.mocked(spawn); mockedSpawn.mockImplementation( () => (({ stderr: new PassThrough(), stdout: new PassThrough(), stdin: new PassThrough(), } as unknown) as ChildProcessWithoutNullStreams), ); describe('startNvimProcess', () => { test('init nvim process with spawn', () => { startNvimProcess(); expect(mockedSpawn).toHaveBeenCalledWith( 'nvim', ['--embed', '--cmd', 'source bin/vv.vim'], expect.anything(), ); }); test.todo('TODO: test vvSourceCommand'); test.todo('TODO: test nvimCommand'); test.todo('TODO: test env'); test.todo('TODO: test cwd'); }); ================================================ FILE: packages/nvim/src/__tests__/utils.test.ts ================================================ import { execSync } from 'child_process'; import { shellEnv, nvimVersion, resetCache } from 'src/utils'; jest.mock('child_process'); const mockedExecSync = jest.mocked((execSync as unknown) as () => string); describe('utils', () => { beforeEach(() => { resetCache(); }); describe('shellEnv', () => { const fakeProc = (env = {}) => ({ env, } as NodeJS.Process); test('returns env from bash', () => { mockedExecSync.mockReturnValue(`key1=val1\nkey2=val2`); expect(shellEnv(fakeProc())).toEqual({ key1: 'val1', key2: 'val2' }); expect(mockedExecSync).toHaveBeenCalledWith('/bin/bash -ilc env', { encoding: 'utf-8' }); }); test('returns original env if it has SHLVL', () => { mockedExecSync.mockReturnValue(`key1=val1\nkey2=val2`); expect(shellEnv(fakeProc({ SHLVL: true, key: 'val' }))).toEqual({ SHLVL: true, key: 'val', }); }); test('add default path if something happens', () => { mockedExecSync.mockImplementationOnce(() => { throw new Error(); }); expect(shellEnv(fakeProc({ PATH: 'some/path', key: 'val' }))).toEqual({ PATH: '/usr/local/bin:/opt/homebrew/bin:some/path', key: 'val', }); }); }); describe('nvimVersion', () => { test('find version string from `nvim --version`', () => { mockedExecSync.mockReturnValue(`Something NVIM v1.2.3 Something else`); expect(nvimVersion()).toBe('1.2.3'); expect(mockedExecSync).toHaveBeenCalledWith('nvim --version', expect.any(Object)); }); }); }); ================================================ FILE: packages/nvim/src/browser.ts ================================================ // Only use relative imports here because https://github.com/microsoft/TypeScript/issues/32999#issuecomment-523558695 import Nvim from './Nvim'; export * from './types'; export default Nvim; ================================================ FILE: packages/nvim/src/index.ts ================================================ // Only use relative imports here because https://github.com/microsoft/TypeScript/issues/32999#issuecomment-523558695 // TODO: Bundle .d.ts or something import Nvim from './Nvim'; export { default as startNvimProcess } from './process'; export { default as ProcNvimTransport } from './ProcNvimTransport'; export * from './types'; export { Nvim }; export { shellEnv, nvimCommand, nvimVersion } from './utils'; export default Nvim; ================================================ FILE: packages/nvim/src/process.ts ================================================ import { spawn } from 'child_process'; import path from 'path'; import debounce from 'lodash/debounce'; import { shellEnv, isDev, nvimCommand } from 'src/utils'; import type { ChildProcessWithoutNullStreams } from 'child_process'; const vvSourceCommand = (appPath?: string) => appPath ? `source ${path.join(appPath, isDev('./', '../'), 'bin/vv.vim')}` : 'source bin/vv.vim'; let nvimProcess; const startNvimProcess = ({ args = [], cwd, appPath, }: { args?: string[]; cwd?: string; appPath?: string; } = {}): ChildProcessWithoutNullStreams => { const env = shellEnv(); const nvimArgs = ['--embed', '--cmd', vvSourceCommand(appPath), ...args]; nvimProcess = spawn(nvimCommand(env), nvimArgs, { cwd, env }); // Pipe errors to std output and also send it in console as error. let errorStr = ''; nvimProcess.stderr.pipe(process.stdout); nvimProcess.stderr.on('data', (data) => { errorStr += data.toString(); debounce(() => { if (errorStr) console.error(errorStr); // eslint-disable-line no-console errorStr = ''; }, 10)(); }); // nvimProcess.stdout.on('data', (data) => { // console.log(data.toString()); // }); return nvimProcess; }; export default startNvimProcess; ================================================ FILE: packages/nvim/src/types.ts ================================================ /* eslint-disable camelcase */ import type { EventEmitter } from 'events'; import type TypedEventEmitter from 'strict-event-emitter-types'; // Only use relative imports here because https://github.com/microsoft/TypeScript/issues/32999#issuecomment-523558695 // TODO: Bundle .d.ts or something import type { UiEvents as UiEventsOriginal, NvimCommands as NvimCommandsOriginal, } from './__generated__/types'; import { nvimCommandNames } from './__generated__/constants'; export type RequestMessage = [0, number, string, any[]]; export type ResponseMessage = [1, number, any, any]; export type NotificationMessage = [2, string, any[]]; export type MessageType = RequestMessage | ResponseMessage | NotificationMessage; export type ReadCallback = (message: MessageType) => void; export type OnCloseCallback = () => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Args = any[]; export type Listener = (...args: Args) => void; /** * Remote transport between server or main and renderer. * Use emitter events (`on`, `once` etc) for receiving message, and `send` to send message to other side. */ export type Transport = EventEmitter & { /** * Send message to remote */ send: (channel: string, ...args: Args) => void; }; // Manual refine of the auto-generated UiEvents // More info: https://neovim.io/doc/user/ui.html export type ModeInfo = { cursor_shape: 'block' | 'horizontal' | 'vertical'; cell_percentage: number; blinkwait: number; blinkon: number; blinkoff: number; attr_id: number; attr_id_lm: number; short_name: string; // TODO: union name: string; // TODO: union mouse_shape: number; }; // TODO: refine this type as a union of `[option, value]` with the correct value type for each option. export type OptionSet = [ option: | 'arabicshape' | 'ambiwidth' | 'emoji' | 'guifont' | 'guifontwide' | 'linespace' | 'mousefocus' | 'pumblend' | 'showtabline' | 'termguicolors' | 'rgb' | 'ext_cmdline' | 'ext_popupmenu' | 'ext_tabline' | 'ext_wildmenu' | 'ext_messages' | 'ext_linegrid' | 'ext_multigrid' | 'ext_hlstate' | 'ext_termcolors', value: boolean | string, ]; export type HighlightAttrs = { foreground?: number; background?: number; special?: number; reverse?: boolean; standout?: boolean; italic?: boolean; bold?: boolean; underline?: boolean; undercurl?: boolean; strikethrough?: boolean; blend?: number; }; export type Cell = [text: string, hl_id?: number, repeat?: number]; type UiEventsPatch = { mode_info_set: [enabled: boolean, cursor_styles: ModeInfo[]]; option_set: OptionSet; hl_attr_define: [id: number, rgb_attrs: HighlightAttrs, cterm_attrs: HighlightAttrs, info: []]; grid_line: [grid: number, row: number, col_start: number, cells: Cell[]]; }; export type UiEvents = Omit & UiEventsPatch; export type UiEventsHandlers = { [Key in keyof UiEvents]: (params: Array) => void; }; type UiEventsArgsByKey = { [Key in keyof UiEvents]: [Key, ...Array]; }; export type UiEventsArgs = Array; export interface NvimEvents { redraw: (args: UiEventsArgs) => void; close: () => void; [x: string]: (...args: any[]) => void; } type NvimCommandsPatch = { nvim_get_mode: () => { mode: string }; }; export type NvimCommands = Omit & NvimCommandsPatch; type NvimCommandsMethods = { [K in keyof typeof nvimCommandNames]: < Return = ReturnType >( ...args: Parameters ) => Promise; }; export type NvimInterface = TypedEventEmitter & NvimCommandsMethods; ================================================ FILE: packages/nvim/src/utils.ts ================================================ import { execSync } from 'child_process'; type IsDevFunction = { (dev: T, notDev: F): T | F; (): boolean; }; export const isDev: IsDevFunction = (dev = true, notDev = false) => process.env.NODE_ENV === 'development' ? dev : notDev; export const nvimCommand = (env: NodeJS.ProcessEnv = {}): string => env.VV_NVIM_COMMAND || process.env.VV_NVIM_COMMAND || 'nvim'; /** * Cached patched `process.env` used in `shellEnv` function. */ let env: NodeJS.ProcessEnv | undefined; /** * Find env variables if the app is started from Finder. We need a correct PATH variable to * start nvim. */ export const shellEnv = (proc = process): NodeJS.ProcessEnv => { if (!env) { env = proc.env; // If we start app from terminal, it will have SHLVL variable. Then we already have correct // env variables and can skip this. if (!env.SHLVL) { try { // Try to get user's default shell and get env from it. const envString = execSync(`${env.SHELL || '/bin/bash'} -ilc env`, { encoding: 'utf-8' }); env = envString .split('\n') .filter(Boolean) .reduce((result, line) => { const [key, ...vals] = line.split('='); return { ...result, [key]: vals.join('='), }; }, {}); } catch (e) { // Most likely nvim is here: // * `/usr/local/bin` Homebrew default bin path // * `/opt/homebrew/bin` Homebrew bin path for Apple Silicon (https://docs.brew.sh/Installation) env.PATH = `/usr/local/bin:/opt/homebrew/bin:${env.PATH}`; } } } return env; }; /** * Cached Neovim version used in `nvimVersion` function. */ let version: string | undefined | null; /** * Get Neovim version string. */ export const nvimVersion = (): string | undefined | null => { if (version !== undefined) return version; const shEnv = shellEnv(); try { const execResult = execSync(`${nvimCommand(shEnv)} --version`, { encoding: 'utf-8', env: shEnv, }); if (execResult) { const match = execResult.match(/NVIM v(\d+)\.(\d+).(\d+)(.*)/); if (match) { version = `${match[1]}.${match[2]}.${match[3]}${match[4]}`; } } } catch (e) { version = null; } return version; }; /** @deprecated helper function for tests */ export const resetCache = (): void => { version = undefined; env = undefined; }; ================================================ FILE: packages/nvim/tsconfig.declaration.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "emitDeclarationOnly": true, "declarationMap": true, "noEmit": false, "outDir": "dist" }, "include": ["src/index.ts", "src/browser.ts", "@types"] } ================================================ FILE: packages/nvim/tsconfig.json ================================================ { "extends": "../../tsconfig", "compilerOptions": { "baseUrl": "." }, "include": ["src", "@types"] } ================================================ FILE: packages/server/README.md ================================================ # VV Server Run Neovim remotely in browser via VV Server: ``` yarn start ``` Then open [http://localhost:3000](http://localhost:3000) You can run in in dev mode in watch mode: ``` yarn dev ``` Server is in a very early stage of development. Please check this milestone for development status: [https://github.com/vv-vim/vv/milestone/1](https://github.com/vv-vim/vv/milestone/1). ================================================ FILE: packages/server/babel.config.json ================================================ { "extends": "../../babel.config.json" } ================================================ FILE: packages/server/bin/vv.vim ================================================ let g:vv = 1 source :h/vvset.vim set termguicolors autocmd VimEnter * call rpcnotify(get(g:, "vv_channel", 1), "vv:vim_enter") " Send unsaved buffers to client function! VVunsavedBuffers() let l:buffers = getbufinfo() call filter(l:buffers, "v:val['changed'] == 1") let l:buffers = map(l:buffers , "{ 'name': v:val['name'] }" ) return l:buffers endfunction ================================================ FILE: packages/server/bin/vvset.vim ================================================ let g:vv_settings_synonims = { \ 'fu': 'fullscreen', \ 'sfu': 'simplefullscreen', \ 'width': 'windowwidth', \ 'height': 'windowheight', \ 'top': 'windowtop', \ 'left': 'windowleft', \ 'openinproject': 'openInProject' \} let g:vv_default_settings = { \ 'fullscreen': 0, \ 'simplefullscreen': 1, \ 'bold': 1, \ 'italic': 1, \ 'underline': 1, \ 'undercurl': 1, \ 'strikethrough': 1, \ 'fontfamily': 'monospace', \ 'fontsize': 12, \ 'lineheight': 1.25, \ 'letterspacing': 0, \ 'reloadchanged': 0, \ 'windowwidth': v:null, \ 'windowheight': v:null, \ 'windowleft': v:null, \ 'windowtop': v:null, \ 'quitoncloselastwindow': 0, \ 'autoupdateinterval': 1440, \ 'openInProject': 1 \} let g:vv_settings = deepcopy(g:vv_default_settings) " Custom VVset command, mimic default set command (:help set) with " settings specified in g:vv_default_settings function! VVset(...) for arg in a:000 call VVsetItem(arg) endfor endfunction function! VVsettingValue(name) let l:name = VVsettingName(a:name) if has_key(g:vv_settings, l:name) return g:vv_settings[l:name] else echoerr "Unknown option: ".a:name endif endfunction function! VVsettingName(name) if has_key(g:vv_settings_synonims, a:name) return g:vv_settings_synonims[a:name] else return a:name endif endfunction function! VVsetItem(name) if a:name == 'all' echo g:vv_settings return elseif a:name =~ '?' let l:name = VVsettingName(split(a:name, '?')[0]) echo VVsettingValue(l:name) return elseif a:name =~ '&' let l:name = VVsettingName(split(a:name, '&')[0]) if l:name == 'all' let g:vv_settings = deepcopy(g:vv_default_settings) call VVsettings() return elseif has_key(g:vv_default_settings, l:name) let l:value = g:vv_default_settings[l:name] else echoerr "Unknown option: ".l:name return endif elseif a:name =~ '+=' let l:split = split(a:name, '+=') let l:name = VVsettingName(l:split[0]) let l:value = VVsettingValue(l:name) + l:split[1] elseif a:name =~ '-=' let l:split = split(a:name, '-=') let l:name = VVsettingName(l:split[0]) let l:value = VVsettingValue(l:name) - l:split[1] elseif a:name =~ '\^=' let l:split = split(a:name, '\^=') let l:name = VVsettingName(l:split[0]) let l:value = VVsettingValue(l:name) * l:split[1] elseif a:name =~ '=' let l:split = split(a:name, '=') let l:name = l:split[0] let l:value = l:split[1] elseif a:name =~ ':' let l:split = split(a:name, ':') let l:name = l:split[0] let l:value = l:split[1] elseif a:name =~ '!' let l:name = VVsettingName(split(a:name, '!')[0]) if VVsettingValue(l:name) == 0 let l:value = 1 else let l:value = 0 endif elseif a:name =~ '^inv' let l:name = VVsettingName(strpart(a:name, 3)) if VVsettingValue(l:name) == 0 let l:value = 1 else let l:value = 0 endif elseif a:name =~ '^no' let l:name = strpart(a:name, 2) let l:value = 0 else let l:name = a:name let l:value = 1 endif let l:name = VVsettingName(l:name) if has_key(g:vv_settings, l:name) let g:vv_settings[l:name] = l:value call rpcnotify(get(g:, "vv_channel", 1), "vv:set", l:name, l:value) else echoerr "Unknown option: ".l:name endif endfunction function! VVsettings() for key in keys(g:vv_settings) call rpcnotify(get(g:, "vv_channel", 1), "vv:set", key, g:vv_settings[key]) endfor endfunction command! -nargs=* VVset :call VVset() command! -nargs=* VVse :call VVset() command! -nargs=0 VVsettings :call VVsettings() " Send all settings to client ================================================ FILE: packages/server/config/webpack.common.config.js ================================================ const path = require('path'); const buildPath = path.resolve(__dirname, './../build'); module.exports = { mode: 'development', output: { path: buildPath, }, resolve: { extensions: ['.ts', '.js'], }, module: { rules: [ { test: /\.(js|ts)$/, exclude: /node_modules/, loader: 'babel-loader', }, ], }, }; ================================================ FILE: packages/server/config/webpack.config.js ================================================ const rendererConfig = require('./webpack.renderer.config'); const serverConfig = require('./webpack.server.config'); module.exports = [rendererConfig, serverConfig]; ================================================ FILE: packages/server/config/webpack.prod.config.js ================================================ const { merge } = require('webpack-merge'); const rendererConfig = require('./webpack.renderer.config'); const serverConfig = require('./webpack.server.config'); const prod = { mode: 'production', devtool: 'source-map', }; const rendererConfigProd = merge(rendererConfig, prod); const mainConfigProd = merge(serverConfig, prod); module.exports = [rendererConfigProd, mainConfigProd]; ================================================ FILE: packages/server/config/webpack.renderer.config.js ================================================ const { merge } = require('webpack-merge'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const common = require('./webpack.common.config'); const config = merge(common, { entry: './src/renderer/index.ts', output: { filename: 'renderer.js', }, plugins: [ new HtmlWebpackPlugin({ template: './src/renderer/index.html', }), ], target: 'web', devtool: 'eval-cheap-source-map', }); module.exports = config; ================================================ FILE: packages/server/config/webpack.server.config.js ================================================ const { merge } = require('webpack-merge'); const common = require('./webpack.common.config'); const config = merge(common, { entry: './src/server/index.ts', target: 'node', output: { filename: 'server.js', }, resolve: { extensions: ['.ts', '.js'], }, }); module.exports = config; ================================================ FILE: packages/server/jest.config.js ================================================ module.exports = { clearMocks: true, moduleNameMapper: { 'src/(.*)': ['/src/$1'], }, }; ================================================ FILE: packages/server/package.json ================================================ { "name": "@vvim/server", "version": "0.0.1", "description": "VV Server: Run Neovim remotely in browser", "author": "Igor Gladkoborodov ", "keywords": [ "vim", "neovim", "client", "gui", "electron" ], "license": "MIT", "main": "./build/main.js", "sideEffects": false, "scripts": { "test": "jest", "clean": "rm -rf dist/*", "webpack:dev": "webpack --watch --config ./config/webpack.config.js", "webpack:prod": "webpack --config ./config/webpack.prod.config.js", "server:dev": "nodemon build/server.js", "server": "node build/server.js", "dev": "npm-run-all --parallel webpack:dev server:dev", "build": "npm-run-all clean webpack:prod", "start": "yarn server" }, "browserslist": [ "maintained node versions" ], "devDependencies": { "@types/express": "^4.17.11", "@types/lodash": "^4.14.168", "@types/node": "^14.14.31", "@types/ws": "^7.4.0", "html-webpack-plugin": "^5.6.0", "node-fetch": "^2.6.7", "nodemon": "^2.0.7" }, "dependencies": { "@vvim/browser-renderer": "0.0.1", "@vvim/nvim": "0.0.1", "express": "^4.17.1", "lodash": "^4.17.21", "semver": "^7.5.2", "ws": "^7.4.6" } } ================================================ FILE: packages/server/src/lib/isDev.ts ================================================ type IsDevFunction = { (dev: T, notDev: F): T | F; (): boolean; }; const isDev: IsDevFunction = (dev = true, notDev = false) => process.env.NODE_ENV === 'development' ? dev : notDev; export default isDev; ================================================ FILE: packages/server/src/renderer/index.html ================================================ VV ================================================ FILE: packages/server/src/renderer/index.ts ================================================ import renderer from '@vvim/browser-renderer'; renderer(); ================================================ FILE: packages/server/src/server/index.ts ================================================ import express from 'express'; import http from 'http'; import initNvim from 'src/server/nvim/nvim'; import { getDefaultSettings } from 'src/server/nvim/settings'; import websocketTransport from 'src/server/transport/websocket'; import { Transport } from '@vvim/nvim'; const { PORT = 3000 } = process.env; const app = express(); const server = http.createServer(app); app.use(express.static('build')); const onConnect = (transport: Transport) => { const args = process.argv.slice(2); initNvim({ transport, args, }); transport.send('initRenderer', getDefaultSettings()); }; websocketTransport({ server, onConnect }); server.listen(PORT, () => { // eslint-disable-next-line no-console console.log(`Server started at http://localhost:${PORT}`); }); ================================================ FILE: packages/server/src/server/nvim/nvim.ts ================================================ // TODO // import quit from '@main/nvim/features/quit'; // import windowTitle from '@main/nvim/features/windowTitle'; // import zoom from '@main/nvim/features/zoom'; // import windowSize from '@main/nvim/features/windowSize'; // import focusAutocmd from '@main/nvim/features/focusAutocmd'; import initSettings from 'src/server/nvim/settings'; import Nvim, { startNvimProcess, ProcNvimTransport, Transport } from '@vvim/nvim'; const initNvim = ({ args, cwd, transport, }: { args?: string[]; cwd?: string; transport: Transport; }): void => { const proc = startNvimProcess({ args, cwd }); const nvimTransport = new ProcNvimTransport(proc, transport); const nvim = new Nvim(nvimTransport); initSettings({ nvim, args, transport }); // TODO // nvim.on('disconnect', () => {}); }; export default initNvim; ================================================ FILE: packages/server/src/server/nvim/settings.ts ================================================ import debounce from 'lodash/debounce'; import type { Nvim, Transport } from '@vvim/nvim'; type BooleanSetting = 0 | 1; export type Settings = { fullscreen: BooleanSetting; simplefullscreen: BooleanSetting; bold: BooleanSetting; italic: BooleanSetting; underline: BooleanSetting; undercurl: BooleanSetting; strikethrough: BooleanSetting; fontfamily: string; fontsize: string; // TODO: number lineheight: string; // TODO: number letterspacing: string; // TODO: number reloadchanged: BooleanSetting; quitoncloselastwindow: BooleanSetting; autoupdateinterval: string; // TODO: number openInProject: BooleanSetting; }; export type SettingsCallback = (newSettings: Partial, allSettings: Settings) => void; export const getDefaultSettings = (): Settings => ({ fullscreen: 0, simplefullscreen: 1, bold: 1, italic: 1, underline: 1, undercurl: 1, strikethrough: 1, fontfamily: 'monospace', fontsize: '12', lineheight: '1.25', letterspacing: '0', reloadchanged: 0, quitoncloselastwindow: 0, autoupdateinterval: '1440', // One day, 60*24 minutes openInProject: 0, }); let hasCustomConfig = false; const initSettings = ({ nvim, args, transport, }: { nvim: Nvim; args?: string[]; transport: Transport; }): void => { hasCustomConfig = args?.indexOf('-u') !== -1; let initialSettings: Settings | null = getDefaultSettings(); let settings = getDefaultSettings(); let newSettings: Partial = {}; const applyAllSettings = async () => { settings = { ...settings, ...newSettings, }; // If we have initial settings newSetting will be only those that different from initialSettings. We // aleady applied initialSettings when we created a window. if (initialSettings && !hasCustomConfig) { newSettings = Object.keys(settings).reduce>((result, key) => { // @ts-expect-error TODO FIXME if (initialSettings[key] !== settings[key]) { return { ...result, // @ts-expect-error TODO FIXME [key]: settings[key], }; } return result; }, {}); initialSettings = null; } transport.send('updateSettings', newSettings, settings); newSettings = {}; }; const debouncedApplyAllSettings = debounce(applyAllSettings, 10); const applySetting = ([option, props]: [K, Settings[K]]) => { if (props !== null) { newSettings[option] = props; debouncedApplyAllSettings(); } }; nvim.on('vv:set', applySetting); }; export default initSettings; ================================================ FILE: packages/server/src/server/transport/websocket.ts ================================================ import WebSocket from 'ws'; import { Server } from 'http'; import { Transport, Args } from '@vvim/nvim'; import { EventEmitter } from 'events'; class WsTransport extends EventEmitter implements Transport { ws: WebSocket; constructor(ws: WebSocket) { super(); this.ws = ws; this.ws.on('message', (data: string) => { try { const [channel, ...args] = JSON.parse(data); this.emit(channel, ...args); } catch (e) { /* empty */ } }); } send(channel: string, ...args: Args) { this.ws.send(JSON.stringify([channel, ...args])); } } /** * Init transport between main and renderer via websocket on server side. */ const transport = ({ server, onConnect, }: { server: Server; onConnect: (t: Transport) => void; }): void => { const wss = new WebSocket.Server({ server }); // TODO: handle disconnect wss.on('connection', (ws) => { onConnect(new WsTransport(ws)); }); }; export default transport; ================================================ FILE: packages/server/tsconfig.json ================================================ { "extends": "../../tsconfig", "compilerOptions": { "baseUrl": "." }, "include": ["src", "@types"] } ================================================ FILE: scripts/codegen.ts ================================================ /* eslint-disable camelcase */ import { spawn } from 'child_process'; import { createDecodeStream, encode } from 'msgpack-lite'; import { writeFileSync } from 'fs'; import prettier from 'prettier'; import camelCase from 'lodash/camelCase'; const TYPES_FILE_NAME = 'packages/nvim/src/__generated__/types.ts'; const CONST_FILE_NAME = 'packages/nvim/src/__generated__/constants.ts'; const nvimProcess = spawn('nvim', ['--embed', '-u', 'NONE']); nvimProcess.stderr.pipe(process.stdout); const decodeStream = createDecodeStream(); const msgpackIn = nvimProcess.stdout.pipe(decodeStream); const replaceType = (originalType: string) => { const replacements = { Array: 'Array', String: 'string', Integer: 'number', Boolean: 'boolean', Float: 'number', Dictionary: 'Record', Object: 'any', Window: 'number', Buffer: 'number', Tabpage: 'number', LuaRef: 'any', 'ArrayOf(String)': 'string[]', 'ArrayOf(Integer)': 'number[]', 'ArrayOf(Integer, 2)': '[number, number]', 'ArrayOf(Dictionary)': 'Record[]', 'ArrayOf(Window)': 'number[]', 'ArrayOf(Buffer)': 'number[]', 'ArrayOf(Tabpage)': 'number[]', } as Record; return replacements[originalType] || originalType; }; const replaceName = (originalName: string) => { const replacements = { window: 'win', } as Record; return replacements[originalName] || originalName; }; msgpackIn.on('data', (data) => { const apiInfo = data[3][1]; writeFileSync('tmp/apiInfo.json', JSON.stringify(apiInfo, null, 2), { encoding: 'utf8' }); const { ui_events, functions } = apiInfo; let result: string[] = []; const version = [apiInfo.version.major, apiInfo.version.minor, apiInfo.version.patch].join('.'); result.push('/* eslint-disable camelcase */'); result.push('/**'); result.push(' * Types generated by `yarn generate-types`. Do not edit manually.'); result.push(' * '); result.push(` * Version: ${version}`); result.push(` * Api Level: ${apiInfo.version.api_level}`); result.push(` * Api Compatible: ${apiInfo.version.api_compatible}`); result.push(` * Api Prerelease: ${apiInfo.version.api_prerelease}`); result.push(' */'); result.push(''); result.push('/**'); result.push(' * UI events types emitted by `redraw` event. Do not edit manually.'); result.push(' * More info: https://neovim.io/doc/user/ui.html'); result.push(' */'); result.push('export type UiEvents = {'); ui_events.forEach(({ name, parameters }: { name: string; parameters: string[][] }) => { const parametersType = parameters.map(([type, typeName]) => { return `${typeName}: ${replaceType(type)}`; }); result.push(` ${name}: [${parametersType.join(', ')}];\n`); }); result.push('}\n'); result.push('/**'); result.push(' * Nvim commands.'); result.push(' * More info: https://neovim.io/doc/user/api.html'); result.push(' */'); result.push('export type NvimCommands = {'); functions .filter((f) => !f.deprecated_since) .forEach( ({ name, parameters, return_type, }: { name: string; parameters: string[][]; return_type: string; }) => { const parametersType = parameters.map(([type, typeName]) => { return `${replaceName(typeName)}: ${replaceType(type)}`; }); result.push(` ${name}: (${parametersType.join(', ')}) => ${replaceType(return_type)};\n`); }, ); result.push('}\n'); const prettifiedTypes = prettier.format(result.join('\n'), { parser: 'typescript' }); writeFileSync(TYPES_FILE_NAME, prettifiedTypes, { encoding: 'utf8', }); result = []; result.push('/* eslint-disable camelcase */'); result.push('/**'); result.push(' * Constants generated by `yarn generate-types`. Do not edit manually.'); result.push(' * '); result.push(` * Version: ${version}`); result.push(` * Api Level: ${apiInfo.version.api_level}`); result.push(` * Api Compatible: ${apiInfo.version.api_compatible}`); result.push(` * Api Prerelease: ${apiInfo.version.api_prerelease}`); result.push(' */'); result.push(''); result.push('export const nvimCommandNames = {'); functions .filter((f) => !f.deprecated_since) .forEach(({ name }: { name: string }) => { result.push(` ${camelCase(name.replace('nvim_', ''))}: '${name}',`); }); result.push('} as const;\n'); const prettifiedConst = prettier.format(result.join('\n'), { parser: 'typescript' }); writeFileSync(CONST_FILE_NAME, prettifiedConst, { encoding: 'utf8', }); }); nvimProcess.stdin.write(encode([0, 1, 'nvim_get_api_info', []])); setTimeout(() => { nvimProcess.stdin.write(encode([0, 1, 'nvim_command', ['q']])); }, 100); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "esnext", "allowJs": true, "noEmit": true, "strict": true, "moduleResolution": "node", "sourceMap": true, "declaration": true, "skipLibCheck": true, "allowSyntheticDefaultImports": true, "isolatedModules": true } }