├── .nvmrc ├── .husky ├── .gitignore └── pre-commit ├── packages ├── server │ ├── babel.config.json │ ├── src │ │ ├── renderer │ │ │ ├── index.ts │ │ │ └── index.html │ │ ├── lib │ │ │ └── isDev.ts │ │ └── server │ │ │ ├── index.ts │ │ │ ├── nvim │ │ │ ├── nvim.ts │ │ │ └── settings.ts │ │ │ └── transport │ │ │ └── websocket.ts │ ├── jest.config.js │ ├── tsconfig.json │ ├── config │ │ ├── webpack.config.js │ │ ├── webpack.server.config.js │ │ ├── webpack.prod.config.js │ │ ├── webpack.common.config.js │ │ └── webpack.renderer.config.js │ ├── bin │ │ ├── vv.vim │ │ └── vvset.vim │ ├── README.md │ └── package.json ├── electron │ ├── babel.config.json │ ├── assets │ │ ├── icon.icns │ │ ├── generic.icns │ │ └── screenshot.png │ ├── src │ │ ├── renderer │ │ │ ├── index.ts │ │ │ └── index.html │ │ ├── main │ │ │ ├── nvim │ │ │ │ ├── __tests__ │ │ │ │ │ └── nvim.test.ts │ │ │ │ ├── features │ │ │ │ │ ├── backrdoundColor.ts │ │ │ │ │ ├── closeWindow.ts │ │ │ │ │ ├── focusAutocmd.ts │ │ │ │ │ ├── copyPaste.ts │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ ├── backrdoundColor.test.ts │ │ │ │ │ │ └── windowSize.test.ts │ │ │ │ │ ├── windowTitle.ts │ │ │ │ │ ├── zoom.ts │ │ │ │ │ ├── quit.ts │ │ │ │ │ ├── reloadChanged.ts │ │ │ │ │ └── windowSize.ts │ │ │ │ ├── nvimByWindow.ts │ │ │ │ ├── nvim.ts │ │ │ │ └── settings.ts │ │ │ ├── preload.js │ │ │ ├── lib │ │ │ │ ├── which.ts │ │ │ │ ├── store.ts │ │ │ │ ├── args.ts │ │ │ │ └── __tests__ │ │ │ │ │ └── args.test.ts │ │ │ ├── checkNeovim.ts │ │ │ ├── installCli.ts │ │ │ ├── transport │ │ │ │ ├── ipc.ts │ │ │ │ └── __tests__ │ │ │ │ │ └── ipc.test.ts │ │ │ ├── autoUpdate.ts │ │ │ ├── menu.ts │ │ │ └── index.ts │ │ └── lib │ │ │ ├── isDev.ts │ │ │ └── log.ts │ ├── jest.config.js │ ├── tsconfig.json │ ├── @types │ │ └── html2plaintext.d.ts │ ├── config │ │ ├── webpack.config.js │ │ ├── webpack.main.config.js │ │ ├── webpack.prod.config.js │ │ ├── webpack.common.config.js │ │ ├── webpack.renderer.config.js │ │ └── electron-builder │ │ │ ├── release.js │ │ │ └── build.js │ ├── README.md │ ├── bin │ │ ├── vv.vim │ │ ├── reloadChanged.vim │ │ ├── vv │ │ ├── openInProject.vim │ │ └── vvset.vim │ ├── scripts │ │ └── filetypes.js │ └── package.json ├── nvim │ ├── README.md │ ├── tsconfig.json │ ├── jest.config.js │ ├── src │ │ ├── browser.ts │ │ ├── index.ts │ │ ├── __tests__ │ │ │ ├── process.test.ts │ │ │ ├── utils.test.ts │ │ │ ├── ProcNvimTransport.test.ts │ │ │ └── Nvim.test.ts │ │ ├── process.ts │ │ ├── ProcNvimTransport.ts │ │ ├── utils.ts │ │ ├── Nvim.ts │ │ ├── types.ts │ │ └── __generated__ │ │ │ └── constants.ts │ ├── babel.config.json │ ├── tsconfig.declaration.json │ ├── config │ │ ├── webpack.prod.config.js │ │ └── webpack.config.js │ └── package.json └── browser-renderer │ ├── config │ ├── jest │ │ ├── afterEnv.js │ │ ├── globalSetup.js │ │ ├── globalTeardown.js │ │ └── testServer.js │ ├── webpack.prod.config.js │ └── webpack.config.js │ ├── tsconfig.json │ ├── src │ ├── lib │ │ ├── isWeb.ts │ │ ├── getColor.ts │ │ └── __tests__ │ │ │ └── getColor.test.ts │ ├── index.ts │ ├── __tests__ │ │ ├── __image_snapshots__ │ │ │ ├── screen-test-ts-screen-overlap-chars-1-snap.png │ │ │ ├── screen-test-ts-screen-overlap-chars-2-snap.png │ │ │ ├── screen-test-ts-screen-match-snapshot-1-snap.png │ │ │ ├── screen-test-ts-screen-show-undercurl-behind-the-text-1-snap.png │ │ │ └── screen-test-ts-screen-redraw-screen-on-default-colors-set-1-snap.png │ │ ├── renderer.test.ts │ │ └── screen.test.ts │ ├── transport │ │ ├── transport.ts │ │ ├── websocket.ts │ │ ├── ipc.ts │ │ └── __tests__ │ │ │ ├── websocket.test.ts │ │ │ └── ipc.test.ts │ ├── features │ │ └── hideMouseCursor.ts │ ├── types.ts │ ├── renderer.ts │ ├── preloaded │ │ └── electron.ts │ └── input │ │ ├── mouse.ts │ │ ├── keyboard.ts │ │ └── __tests__ │ │ └── keyboard.test.ts │ ├── tsconfig.declaration.json │ ├── babel.config.json │ ├── README.md │ ├── jest.config.js │ └── package.json ├── .prettierrc.js ├── codecov.yml ├── jest.config.js ├── .gitignore ├── tsconfig.json ├── babel.config.json ├── LICENSE ├── .github └── workflows │ ├── link_typecheck.yml │ └── tests.yml ├── package.json ├── .eslintrc.js ├── scripts └── codegen.ts └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.9.0 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /packages/server/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../babel.config.json" 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /packages/electron/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../babel.config.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/electron/assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vv-vim/vv/HEAD/packages/electron/assets/icon.icns -------------------------------------------------------------------------------- /packages/electron/src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import renderer from '@vvim/browser-renderer'; 2 | 3 | renderer(); 4 | -------------------------------------------------------------------------------- /packages/server/src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import renderer from '@vvim/browser-renderer'; 2 | 3 | renderer(); 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | printWidth: 100, 4 | singleQuote: true, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/electron/assets/generic.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vv-vim/vv/HEAD/packages/electron/assets/generic.icns -------------------------------------------------------------------------------- /packages/electron/assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vv-vim/vv/HEAD/packages/electron/assets/screenshot.png -------------------------------------------------------------------------------- /packages/nvim/README.md: -------------------------------------------------------------------------------- 1 | # @vvim/nvim 2 | 3 | Lightweight transport agnostic Neovim API client to be used in other @vvim packages. 4 | -------------------------------------------------------------------------------- /packages/electron/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleNameMapper: { 4 | 'src/(.*)': ['/src/$1'], 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/server/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleNameMapper: { 4 | 'src/(.*)': ['/src/$1'], 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/browser-renderer/config/jest/afterEnv.js: -------------------------------------------------------------------------------- 1 | const { toMatchImageSnapshot } = require('jest-image-snapshot'); 2 | 3 | expect.extend({ toMatchImageSnapshot }); 4 | -------------------------------------------------------------------------------- /packages/nvim/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": "." 5 | }, 6 | "include": ["src", "@types"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/electron/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": "." 5 | }, 6 | "include": ["src", "@types"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": "." 5 | }, 6 | "include": ["src", "@types"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/electron/@types/html2plaintext.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'html2plaintext' { 2 | const html2plaintext: (x: string) => string; 3 | export default html2plaintext; 4 | } 5 | -------------------------------------------------------------------------------- /packages/browser-renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": "." 5 | }, 6 | "include": ["src", "@types"] 7 | } 8 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: "reach, diff, flags, files" 3 | require_changes: false 4 | 5 | ignore: 6 | - "packages/browser-renderer/src/screen.ts" # Tested by Puppeteer 7 | 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | testEnvironment: 'node', 4 | collectCoverageFrom: ['src/**/*.{ts,js}'], 5 | projects: ['/packages/*'], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/lib/isWeb.ts: -------------------------------------------------------------------------------- 1 | const isWeb = (): boolean => 2 | window.location.protocol === 'http:' || window.location.protocol === 'https:'; 3 | 4 | export default isWeb; 5 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/__tests__/nvim.test.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | import initNvim from 'src/main/nvim/nvim'; 3 | 4 | describe('initNvim', () => { 5 | test.todo('TODO'); 6 | }); 7 | -------------------------------------------------------------------------------- /packages/electron/config/webpack.config.js: -------------------------------------------------------------------------------- 1 | const rendererConfig = require('./webpack.renderer.config'); 2 | const mainConfig = require('./webpack.main.config'); 3 | 4 | module.exports = [rendererConfig, mainConfig]; 5 | -------------------------------------------------------------------------------- /packages/nvim/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleNameMapper: { 4 | 'src/(.*)': ['/src/$1'], 5 | }, 6 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 7 | }; 8 | -------------------------------------------------------------------------------- /packages/server/config/webpack.config.js: -------------------------------------------------------------------------------- 1 | const rendererConfig = require('./webpack.renderer.config'); 2 | const serverConfig = require('./webpack.server.config'); 3 | 4 | module.exports = [rendererConfig, serverConfig]; 5 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/index.ts: -------------------------------------------------------------------------------- 1 | // Only use relative imports here because https://github.com/microsoft/TypeScript/issues/32999#issuecomment-523558695 2 | import renderer from './renderer'; 3 | 4 | export default renderer; 5 | -------------------------------------------------------------------------------- /packages/nvim/src/browser.ts: -------------------------------------------------------------------------------- 1 | // Only use relative imports here because https://github.com/microsoft/TypeScript/issues/32999#issuecomment-523558695 2 | 3 | import Nvim from './Nvim'; 4 | 5 | export * from './types'; 6 | 7 | export default Nvim; 8 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/__tests__/__image_snapshots__/screen-test-ts-screen-overlap-chars-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vv-vim/vv/HEAD/packages/browser-renderer/src/__tests__/__image_snapshots__/screen-test-ts-screen-overlap-chars-1-snap.png -------------------------------------------------------------------------------- /packages/browser-renderer/src/__tests__/__image_snapshots__/screen-test-ts-screen-overlap-chars-2-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vv-vim/vv/HEAD/packages/browser-renderer/src/__tests__/__image_snapshots__/screen-test-ts-screen-overlap-chars-2-snap.png -------------------------------------------------------------------------------- /packages/browser-renderer/src/__tests__/__image_snapshots__/screen-test-ts-screen-match-snapshot-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vv-vim/vv/HEAD/packages/browser-renderer/src/__tests__/__image_snapshots__/screen-test-ts-screen-match-snapshot-1-snap.png -------------------------------------------------------------------------------- /packages/electron/README.md: -------------------------------------------------------------------------------- 1 | # VV 2 | 3 | VV is a Neovim client for macOS. A pure, fast, minimalistic Vim experience with good macOS integration. Optimized for speed and nice font rendering. 4 | 5 | Please check main readme file for details: [README.md](../../README.md). 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | **/node_modules/** 4 | 5 | **/build/** 6 | **/dist/** 7 | **/coverage/** 8 | **/tmp/** 9 | 10 | .DS_Store 11 | .env 12 | .nyc_output 13 | yarn-error.log 14 | __diff_output__ 15 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/__tests__/__image_snapshots__/screen-test-ts-screen-show-undercurl-behind-the-text-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vv-vim/vv/HEAD/packages/browser-renderer/src/__tests__/__image_snapshots__/screen-test-ts-screen-show-undercurl-behind-the-text-1-snap.png -------------------------------------------------------------------------------- /packages/nvim/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../babel.config.json", 3 | "plugins": [ 4 | [ 5 | "module-resolver", 6 | { 7 | "root": ["."], 8 | "alias": { 9 | "src": "./src" 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/src/lib/isDev.ts: -------------------------------------------------------------------------------- 1 | type IsDevFunction = { 2 | (dev: T, notDev: F): T | F; 3 | (): boolean; 4 | }; 5 | 6 | const isDev: IsDevFunction = (dev = true, notDev = false) => 7 | process.env.NODE_ENV === 'development' ? dev : notDev; 8 | 9 | export default isDev; 10 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/__tests__/__image_snapshots__/screen-test-ts-screen-redraw-screen-on-default-colors-set-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vv-vim/vv/HEAD/packages/browser-renderer/src/__tests__/__image_snapshots__/screen-test-ts-screen-redraw-screen-on-default-colors-set-1-snap.png -------------------------------------------------------------------------------- /packages/electron/src/lib/isDev.ts: -------------------------------------------------------------------------------- 1 | type IsDevFunction = { 2 | (dev: T, notDev: F): T | F; 3 | (): boolean; 4 | }; 5 | 6 | const isDev: IsDevFunction = (dev = true, notDev = false) => 7 | process.env.NODE_ENV === 'development' ? dev : notDev; 8 | 9 | export default isDev; 10 | -------------------------------------------------------------------------------- /packages/browser-renderer/tsconfig.declaration.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "emitDeclarationOnly": true, 5 | "declarationMap": true, 6 | "noEmit": false, 7 | "outDir": "dist" 8 | }, 9 | "include": ["src/index.ts", "@types"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/nvim/tsconfig.declaration.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "emitDeclarationOnly": true, 5 | "declarationMap": true, 6 | "noEmit": false, 7 | "outDir": "dist" 8 | }, 9 | "include": ["src/index.ts", "src/browser.ts", "@types"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/browser-renderer/config/jest/globalSetup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle, no-undef */ 2 | 3 | import { setupTestServer } from './testServer'; 4 | 5 | const globalSetup = async () => { 6 | globalThis.__PUPPETEER_SERVER__ = await setupTestServer(); 7 | }; 8 | 9 | export default globalSetup; 10 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/transport/transport.ts: -------------------------------------------------------------------------------- 1 | import IpcRendererTransport from 'src/transport/ipc'; 2 | import WebSocketTransport from 'src/transport/websocket'; 3 | import isWeb from 'src/lib/isWeb'; 4 | 5 | const Transport = isWeb() ? WebSocketTransport : IpcRendererTransport; 6 | 7 | export default Transport; 8 | -------------------------------------------------------------------------------- /packages/browser-renderer/config/jest/globalTeardown.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle, no-undef */ 2 | 3 | import { teardownTestServer } from './testServer'; 4 | 5 | const globalTeardown = async () => { 6 | await teardownTestServer(globalThis.__PUPPETEER_SERVER__); 7 | }; 8 | 9 | export default globalTeardown; 10 | -------------------------------------------------------------------------------- /packages/browser-renderer/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../babel.config.json", 3 | "plugins": [ 4 | [ 5 | "module-resolver", 6 | { 7 | "root": ["."], 8 | "alias": { 9 | "src": "./src", 10 | "config": "./config" 11 | } 12 | } 13 | ] 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /packages/browser-renderer/README.md: -------------------------------------------------------------------------------- 1 | # @vvim/browser-renderer 2 | 3 | This package is used to render Neovim in browser for [VV Electron App](../electron) and [VV server](../server). 4 | 5 | It is in active development, the API is not stable yet. 6 | 7 | ## Development 8 | 9 | Run in watch mode: 10 | 11 | ``` 12 | yarn dev 13 | ``` 14 | -------------------------------------------------------------------------------- /packages/browser-renderer/config/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | 3 | const webpackConfig = require('./webpack.config'); 4 | 5 | const prod = { 6 | mode: 'production', 7 | devtool: 'source-map', 8 | }; 9 | 10 | const webpackConfigProd = merge(webpackConfig, prod); 11 | 12 | module.exports = webpackConfigProd; 13 | -------------------------------------------------------------------------------- /packages/electron/src/lib/log.ts: -------------------------------------------------------------------------------- 1 | const initNow = Date.now(); 2 | let lastNow = initNow; 3 | const log = (...text: string[]): void => { 4 | // eslint-disable-next-line no-console 5 | console.log(...text, Date.now() - lastNow, Date.now() - initNow, initNow, Date.now()); 6 | lastNow = Date.now(); 7 | }; 8 | 9 | log('Init log'); 10 | 11 | export default log; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "allowJs": true, 5 | "noEmit": true, 6 | "strict": true, 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "declaration": true, 10 | "skipLibCheck": true, 11 | "allowSyntheticDefaultImports": true, 12 | "isolatedModules": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/nvim/config/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | 3 | const webpackConfig = require('./webpack.config'); 4 | 5 | const prod = { 6 | mode: 'production', 7 | devtool: 'source-map', 8 | }; 9 | 10 | const webpackConfigProd = webpackConfig.map((config) => merge(config, prod)); 11 | 12 | module.exports = webpackConfigProd; 13 | -------------------------------------------------------------------------------- /packages/server/config/webpack.server.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.config'); 3 | 4 | const config = merge(common, { 5 | entry: './src/server/index.ts', 6 | target: 'node', 7 | output: { 8 | filename: 'server.js', 9 | }, 10 | resolve: { 11 | extensions: ['.ts', '.js'], 12 | }, 13 | }); 14 | 15 | module.exports = config; 16 | -------------------------------------------------------------------------------- /packages/electron/config/webpack.main.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.config'); 3 | 4 | const config = merge(common, { 5 | entry: './src/main/index.ts', 6 | output: { 7 | filename: 'main.js', 8 | }, 9 | target: 'electron-main', 10 | node: { 11 | __dirname: false, 12 | __filename: false, 13 | }, 14 | }); 15 | 16 | module.exports = config; 17 | -------------------------------------------------------------------------------- /packages/electron/config/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | 3 | const rendererConfig = require('./webpack.renderer.config'); 4 | const mainConfig = require('./webpack.main.config'); 5 | 6 | const prod = { 7 | mode: 'production', 8 | }; 9 | 10 | const rendererConfigProd = merge(rendererConfig, prod); 11 | const mainConfigProd = merge(mainConfig, prod); 12 | 13 | module.exports = [rendererConfigProd, mainConfigProd]; 14 | -------------------------------------------------------------------------------- /packages/server/bin/vv.vim: -------------------------------------------------------------------------------- 1 | let g:vv = 1 2 | 3 | source :h/vvset.vim 4 | 5 | set termguicolors 6 | 7 | autocmd VimEnter * call rpcnotify(get(g:, "vv_channel", 1), "vv:vim_enter") 8 | 9 | " Send unsaved buffers to client 10 | function! VVunsavedBuffers() 11 | let l:buffers = getbufinfo() 12 | call filter(l:buffers, "v:val['changed'] == 1") 13 | let l:buffers = map(l:buffers , "{ 'name': v:val['name'] }" ) 14 | return l:buffers 15 | endfunction 16 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", { "modules": "commonjs" }], "@babel/preset-typescript"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-optional-chaining", 5 | "@babel/plugin-proposal-class-properties", 6 | "@babel/plugin-transform-runtime", 7 | [ 8 | "module-resolver", 9 | { 10 | "root": ["."], 11 | "alias": { 12 | "src": "./src" 13 | } 14 | } 15 | ] 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /packages/electron/src/main/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require('electron'); 2 | 3 | contextBridge.exposeInMainWorld('electron', { 4 | ipcRenderer: { 5 | send: (channel, ...params) => { 6 | ipcRenderer.send(channel, ...params); 7 | }, 8 | on: (channel, callback) => { 9 | ipcRenderer.on(channel, (_event, ...args) => callback(...args)); 10 | }, 11 | removeListener: ipcRenderer.removeListener, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/server/README.md: -------------------------------------------------------------------------------- 1 | # VV Server 2 | 3 | Run Neovim remotely in browser via VV Server: 4 | 5 | ``` 6 | yarn start 7 | ``` 8 | 9 | Then open [http://localhost:3000](http://localhost:3000) 10 | 11 | You can run in in dev mode in watch mode: 12 | 13 | ``` 14 | yarn dev 15 | ``` 16 | 17 | 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). 18 | -------------------------------------------------------------------------------- /packages/server/config/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | 3 | const rendererConfig = require('./webpack.renderer.config'); 4 | const serverConfig = require('./webpack.server.config'); 5 | 6 | const prod = { 7 | mode: 'production', 8 | devtool: 'source-map', 9 | }; 10 | 11 | const rendererConfigProd = merge(rendererConfig, prod); 12 | const mainConfigProd = merge(serverConfig, prod); 13 | 14 | module.exports = [rendererConfigProd, mainConfigProd]; 15 | -------------------------------------------------------------------------------- /packages/browser-renderer/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | clearMocks: true, 4 | moduleNameMapper: { 5 | '\\./src/(.*)': ['/src/$1'], 6 | 'config/(.*)': ['/config/$1'], 7 | }, 8 | testPathIgnorePatterns: ['/node_modules/', '/dist/'], 9 | setupFilesAfterEnv: ['/config/jest/afterEnv.js'], 10 | globalSetup: '/config/jest/globalSetup.js', 11 | globalTeardown: '/config/jest/globalTeardown.js', 12 | }; 13 | -------------------------------------------------------------------------------- /packages/server/config/webpack.common.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const buildPath = path.resolve(__dirname, './../build'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | output: { 8 | path: buildPath, 9 | }, 10 | resolve: { 11 | extensions: ['.ts', '.js'], 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.(js|ts)$/, 17 | exclude: /node_modules/, 18 | loader: 'babel-loader', 19 | }, 20 | ], 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/electron/config/webpack.common.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const buildPath = path.resolve(__dirname, './../build'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | output: { 8 | path: buildPath, 9 | }, 10 | resolve: { 11 | extensions: ['.ts', '.js'], 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.(js|ts)$/, 17 | exclude: /node_modules/, 18 | loader: 'babel-loader', 19 | }, 20 | ], 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /packages/electron/src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | VV 7 | 8 | 9 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/server/src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | VV 7 | 8 | 9 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/backrdoundColor.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWindow } from 'electron'; 2 | import type { Transport } from '@vvim/nvim'; 3 | 4 | /** 5 | * Change Electron window background color depending when renderer ask for it. 6 | */ 7 | const backroundColor = ({ transport, win }: { transport: Transport; win: BrowserWindow }): void => { 8 | transport.on('set-background-color', (bgColor: string) => { 9 | win.setBackgroundColor(bgColor); 10 | }); 11 | }; 12 | 13 | export default backroundColor; 14 | -------------------------------------------------------------------------------- /packages/nvim/src/index.ts: -------------------------------------------------------------------------------- 1 | // Only use relative imports here because https://github.com/microsoft/TypeScript/issues/32999#issuecomment-523558695 2 | // TODO: Bundle .d.ts or something 3 | 4 | import Nvim from './Nvim'; 5 | 6 | export { default as startNvimProcess } from './process'; 7 | 8 | export { default as ProcNvimTransport } from './ProcNvimTransport'; 9 | 10 | export * from './types'; 11 | 12 | export { Nvim }; 13 | 14 | export { shellEnv, nvimCommand, nvimVersion } from './utils'; 15 | 16 | export default Nvim; 17 | -------------------------------------------------------------------------------- /packages/electron/src/main/lib/which.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | import { shellEnv } from '@vvim/nvim'; 4 | 5 | /** 6 | * Checks if command exists in shell. 7 | */ 8 | const which = (command: string): string | null => { 9 | let result: string | null | undefined; 10 | try { 11 | result = execSync(`which ${command}`, { 12 | encoding: 'utf-8', 13 | env: shellEnv(), 14 | }); 15 | } catch (e) { 16 | result = null; 17 | } 18 | return result; 19 | }; 20 | 21 | export default which; 22 | -------------------------------------------------------------------------------- /packages/browser-renderer/config/jest/testServer.js: -------------------------------------------------------------------------------- 1 | import { setup, teardown } from 'jest-dev-server'; 2 | 3 | // TODO: make it configurable 4 | export const PORT = 3001; 5 | 6 | export async function setupTestServer() { 7 | const server = await setup({ 8 | command: `PORT=${PORT} yarn start:server -u NONE`, 9 | launchTimeout: 10000, 10 | port: PORT, 11 | usedPortAction: 'kill', 12 | }); 13 | return server; 14 | } 15 | 16 | export async function teardownTestServer(server) { 17 | if (server) { 18 | await teardown(server); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/electron/config/webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const common = require('./webpack.common.config'); 4 | 5 | const config = merge(common, { 6 | entry: './src/renderer/index.ts', 7 | output: { 8 | filename: 'renderer.js', 9 | }, 10 | plugins: [ 11 | new HtmlWebpackPlugin({ 12 | template: './src/renderer/index.html', 13 | }), 14 | ], 15 | target: 'web', 16 | devtool: 'eval-cheap-source-map', 17 | }); 18 | 19 | module.exports = config; 20 | -------------------------------------------------------------------------------- /packages/server/config/webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const common = require('./webpack.common.config'); 4 | 5 | const config = merge(common, { 6 | entry: './src/renderer/index.ts', 7 | output: { 8 | filename: 'renderer.js', 9 | }, 10 | plugins: [ 11 | new HtmlWebpackPlugin({ 12 | template: './src/renderer/index.html', 13 | }), 14 | ], 15 | target: 'web', 16 | devtool: 'eval-cheap-source-map', 17 | }); 18 | 19 | module.exports = config; 20 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/closeWindow.ts: -------------------------------------------------------------------------------- 1 | import { MenuItemConstructorOptions } from 'electron'; 2 | 3 | import { getNvimByWindow } from 'src/main/nvim/nvimByWindow'; 4 | 5 | export const closeWindowMenuItem: MenuItemConstructorOptions['click'] = async (_item, win) => { 6 | if (win) { 7 | const nvim = getNvimByWindow(win); 8 | if (nvim) { 9 | const isNotLastWindow = await nvim.eval('tabpagenr("$") > 1 || winnr("$") > 1'); 10 | if (isNotLastWindow) { 11 | nvim.command(`q`); 12 | } else { 13 | win.close(); 14 | } 15 | } 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/focusAutocmd.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron'; 2 | 3 | import type Nvim from '@vvim/nvim'; 4 | 5 | /** 6 | * Emit FocusGained or FocusLost autocmd when app window get or loose focus. 7 | * https://neovim.io/doc/user/autocmd.html#FocusGained 8 | */ 9 | const focusAutocmd = ({ win, nvim }: { win: BrowserWindow; nvim: Nvim }): void => { 10 | win.on('focus', () => { 11 | nvim.command('doautocmd FocusGained'); 12 | }); 13 | 14 | win.on('blur', () => { 15 | nvim.command('doautocmd FocusLost'); 16 | }); 17 | }; 18 | 19 | export default focusAutocmd; 20 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/features/hideMouseCursor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Hides mouse cursor when you start typing. Shows it again when you move mouse. 3 | */ 4 | function showCursor() { 5 | document.body.style.cursor = 'auto'; 6 | document.addEventListener('keydown', hideCursor); // eslint-disable-line no-use-before-define 7 | document.removeEventListener('mousemove', showCursor); 8 | } 9 | 10 | function hideCursor(): void { 11 | document.body.style.cursor = 'none'; 12 | document.addEventListener('mousemove', showCursor); 13 | document.removeEventListener('keydown', hideCursor); 14 | } 15 | 16 | export default hideCursor; 17 | -------------------------------------------------------------------------------- /packages/electron/bin/vv.vim: -------------------------------------------------------------------------------- 1 | let g:vv = 1 2 | 3 | let s:dir = expand(':p:h') 4 | 5 | execute 'source ' . fnameescape(s:dir . '/vvset.vim') 6 | execute 'source ' . fnameescape(s:dir . '/reloadChanged.vim') 7 | execute 'source ' . fnameescape(s:dir . '/openInProject.vim') 8 | 9 | set termguicolors 10 | 11 | autocmd VimEnter * call rpcnotify(get(g:, 'vv_channel', 1), "vv:vim_enter") 12 | 13 | " Send unsaved buffers to client 14 | function! VVunsavedBuffers() 15 | let l:buffers = getbufinfo() 16 | call filter(l:buffers, "v:val['changed'] == 1") 17 | let l:buffers = map(l:buffers , "{ 'name': v:val['name'] }" ) 18 | return l:buffers 19 | endfunction 20 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/types.ts: -------------------------------------------------------------------------------- 1 | type BooleanSetting = 0 | 1; 2 | 3 | export type Settings = { 4 | fullscreen: BooleanSetting; 5 | simplefullscreen: BooleanSetting; 6 | bold: BooleanSetting; 7 | italic: BooleanSetting; 8 | underline: BooleanSetting; 9 | undercurl: BooleanSetting; 10 | strikethrough: BooleanSetting; 11 | fontfamily: string; 12 | fontsize: string; // TODO: number 13 | lineheight: string; // TODO: number 14 | letterspacing: string; // TODO: number 15 | reloadchanged: BooleanSetting; 16 | quitoncloselastwindow: BooleanSetting; 17 | autoupdateinterval: string; // TODO: number 18 | openInProject: BooleanSetting; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/browser-renderer/config/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const buildPath = path.resolve(__dirname, './../dist'); 4 | 5 | const config = { 6 | mode: 'development', 7 | entry: './src/index.ts', 8 | output: { 9 | path: buildPath, 10 | filename: 'index.js', 11 | libraryTarget: 'umd', 12 | }, 13 | target: 'web', 14 | devtool: 'eval-cheap-source-map', 15 | resolve: { 16 | extensions: ['.ts', '.js'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(js|ts)$/, 22 | exclude: /node_modules/, 23 | loader: 'babel-loader', 24 | }, 25 | ], 26 | }, 27 | }; 28 | 29 | module.exports = config; 30 | -------------------------------------------------------------------------------- /packages/electron/config/electron-builder/release.js: -------------------------------------------------------------------------------- 1 | // Notarize needs APP_ID, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, TEAM_ID env variables. 2 | // Github repo to release is automatically detected from package.json. 3 | // GH_TOKEN env variable is required to upload release. 4 | 5 | // eslint-disable-next-line import/extensions 6 | const build = require('./build.js'); 7 | 8 | const publish = { 9 | ...build, 10 | mac: { 11 | category: 'public.app-category.developer-tools', 12 | target: { 13 | target: 'default', 14 | arch: 'universal', 15 | }, 16 | notarize: { 17 | teamId: process.env.TEAM_ID, 18 | }, 19 | }, 20 | }; 21 | 22 | module.exports = publish; 23 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/transport/websocket.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import type { Transport, Args } from '@vvim/nvim'; 3 | 4 | /** 5 | * Init transport between main and renderer via WebSocket. 6 | */ 7 | class WebSocketTransport extends EventEmitter implements Transport { 8 | socket: WebSocket; 9 | 10 | constructor() { 11 | super(); 12 | 13 | this.socket = new WebSocket(`ws://${window.location.host}`); 14 | 15 | this.socket.onmessage = ({ data }) => { 16 | const [channel, args] = JSON.parse(data); 17 | this.emit(channel, args); 18 | }; 19 | } 20 | 21 | send(channel: string, ...args: Args): void { 22 | this.socket.send(JSON.stringify([channel, ...args])); 23 | } 24 | } 25 | 26 | export default WebSocketTransport; 27 | -------------------------------------------------------------------------------- /packages/electron/bin/reloadChanged.vim: -------------------------------------------------------------------------------- 1 | " TODO: Remove on the next major version 2 | " Iterate on buffers and reload them from disk. No questions asked. 3 | " Do it in temporary tab to keep the same windows layout. 4 | function! VVrefresh(...) 5 | -tabnew 6 | for bufnr in a:000 7 | execute "buffer" bufnr 8 | execute "e!" 9 | endfor 10 | tabclose! 11 | endfunction 12 | 13 | function! VVenableReloadChanged(enabled) 14 | if a:enabled 15 | augroup ReloadChanged 16 | autocmd! 17 | autocmd FileChangedShell * call rpcnotify(get(g:, "vv_channel", 1), "vv:file_changed", { "name": expand(""), "bufnr": expand("") }) 18 | autocmd CursorHold * checktime 19 | augroup END 20 | else 21 | autocmd! ReloadChanged * 22 | endif 23 | endfunction 24 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/nvimByWindow.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWindow } from 'electron'; 2 | import type Nvim from '@vvim/nvim'; 3 | 4 | const nvimByWindowId: Record = []; 5 | 6 | export const getNvimByWindow = (winOrId?: number | BrowserWindow): Nvim | null => { 7 | if (!winOrId) { 8 | return null; 9 | } 10 | if (typeof winOrId === 'number') { 11 | return nvimByWindowId[winOrId]; 12 | } 13 | if (winOrId.webContents) { 14 | return nvimByWindowId[winOrId.id]; 15 | } 16 | return null; 17 | }; 18 | 19 | export const setNvimByWindow = (win: BrowserWindow, nvim: Nvim): void => { 20 | if (win.webContents) { 21 | nvimByWindowId[win.id] = nvim; 22 | } 23 | }; 24 | 25 | export const deleteNvimByWindow = (win: BrowserWindow): void => { 26 | if (win.webContents) { 27 | delete nvimByWindowId[win.id]; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/renderer.ts: -------------------------------------------------------------------------------- 1 | import Nvim from '@vvim/nvim'; 2 | 3 | import { Settings } from 'src/types'; 4 | 5 | import Transport from 'src/transport/transport'; 6 | import initScreen from 'src/screen'; 7 | import initKeyboard from 'src/input/keyboard'; 8 | import initMouse from 'src/input/mouse'; 9 | import hideMouseCursor from 'src/features/hideMouseCursor'; 10 | 11 | /** 12 | * Browser renderer 13 | */ 14 | const renderer = (): void => { 15 | const transport = new Transport(); 16 | 17 | const initRenderer = (settings: Settings) => { 18 | const nvim = new Nvim(transport, true); 19 | const screen = initScreen({ nvim, settings, transport }); 20 | initKeyboard({ nvim, screen }); 21 | initMouse({ nvim, screen }); 22 | hideMouseCursor(); 23 | }; 24 | 25 | transport.on('initRenderer', initRenderer); 26 | }; 27 | 28 | export default renderer; 29 | -------------------------------------------------------------------------------- /packages/electron/bin/vv: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then 4 | cat << END 5 | VV - NeoVim GUI Client 6 | 7 | Usage: 8 | vv [options] [file ...] 9 | 10 | Options: 11 | --debug Debug mode. Keep process attached to terminal and 12 | output errors. 13 | 14 | All other options will be passed to nvim. You can check available options 15 | by running: nvim --help 16 | END 17 | 18 | else 19 | SOURCE="${BASH_SOURCE[0]}" 20 | while [ -h "$SOURCE" ]; do 21 | DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 22 | SOURCE="$(readlink "$SOURCE")" 23 | [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" 24 | done 25 | DIR="$( cd -P "$( dirname "$SOURCE" )" && pwd )" 26 | BIN="$DIR/../../MacOS/VV" 27 | 28 | if [[ "${@#--debug}" = "$@" ]]; then 29 | exec "$BIN" "$@" &>/dev/null & disown 30 | else 31 | exec "$BIN" "${@#--debug}" 32 | fi 33 | fi 34 | -------------------------------------------------------------------------------- /packages/server/src/server/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import http from 'http'; 3 | 4 | import initNvim from 'src/server/nvim/nvim'; 5 | import { getDefaultSettings } from 'src/server/nvim/settings'; 6 | import websocketTransport from 'src/server/transport/websocket'; 7 | 8 | import { Transport } from '@vvim/nvim'; 9 | 10 | const { PORT = 3000 } = process.env; 11 | 12 | const app = express(); 13 | const server = http.createServer(app); 14 | 15 | app.use(express.static('build')); 16 | 17 | const onConnect = (transport: Transport) => { 18 | const args = process.argv.slice(2); 19 | 20 | initNvim({ 21 | transport, 22 | args, 23 | }); 24 | transport.send('initRenderer', getDefaultSettings()); 25 | }; 26 | 27 | websocketTransport({ server, onConnect }); 28 | 29 | server.listen(PORT, () => { 30 | // eslint-disable-next-line no-console 31 | console.log(`Server started at http://localhost:${PORT}`); 32 | }); 33 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/copyPaste.ts: -------------------------------------------------------------------------------- 1 | import { clipboard, MenuItemConstructorOptions } from 'electron'; 2 | 3 | import { getNvimByWindow } from 'src/main/nvim/nvimByWindow'; 4 | 5 | export const pasteMenuItem: MenuItemConstructorOptions['click'] = async (_item, win) => { 6 | const nvim = getNvimByWindow(win); 7 | if (nvim) { 8 | const clipboardText = clipboard.readText(); 9 | nvim.paste(clipboardText, true, -1); 10 | } 11 | }; 12 | 13 | export const copyMenuItem: MenuItemConstructorOptions['click'] = async (_item, win) => { 14 | const nvim = getNvimByWindow(win); 15 | if (nvim) { 16 | const mode = await nvim.getShortMode(); 17 | if (mode === 'v' || mode === 'V') { 18 | nvim.input('"*y'); 19 | } 20 | } 21 | }; 22 | 23 | export const selectAllMenuItem: MenuItemConstructorOptions['click'] = (_item, win) => { 24 | const nvim = getNvimByWindow(win); 25 | if (nvim) { 26 | nvim.input('ggVG'); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /packages/electron/src/main/lib/store.ts: -------------------------------------------------------------------------------- 1 | import Store from 'electron-store'; 2 | 3 | type BooleanSetting = 0 | 1; 4 | 5 | export type Settings = { 6 | fullscreen: BooleanSetting; 7 | simplefullscreen: BooleanSetting; 8 | bold: BooleanSetting; 9 | italic: BooleanSetting; 10 | underline: BooleanSetting; 11 | undercurl: BooleanSetting; 12 | strikethrough: BooleanSetting; 13 | fontfamily: string; 14 | fontsize: string; // TODO: number 15 | lineheight: string; // TODO: number 16 | letterspacing: string; // TODO: number 17 | reloadchanged: BooleanSetting; 18 | quitoncloselastwindow: BooleanSetting; 19 | autoupdateinterval: string; // TODO: number 20 | openInProject: BooleanSetting; 21 | }; 22 | 23 | type StoreData = { 24 | lastSettings: Settings; 25 | autoUpdate: { 26 | lastCheckedForUpdate: number; 27 | }; 28 | 'autoUpdate.lastCheckedForUpdate': number; 29 | }; 30 | 31 | const store = new Store(); 32 | 33 | export default store; 34 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/__tests__/backrdoundColor.test.ts: -------------------------------------------------------------------------------- 1 | import backgroundColor from 'src/main/nvim/features/backrdoundColor'; 2 | 3 | import type { Transport } from '@vvim/nvim'; 4 | import type { BrowserWindow } from 'electron'; 5 | 6 | describe('backrdoundColor', () => { 7 | const setBackgroundColor = jest.fn(); 8 | let emitSetBackgroundColor: (color: string) => void; 9 | 10 | const transport = ({ 11 | on: (event: string, callback: (...args: any[]) => void) => { 12 | if (event === 'set-background-color') { 13 | emitSetBackgroundColor = callback; 14 | } 15 | }, 16 | } as unknown) as Transport; 17 | 18 | const win = ({ 19 | setBackgroundColor, 20 | } as unknown) as BrowserWindow; 21 | 22 | test('set window background color on `set-backround-color` event', () => { 23 | backgroundColor({ transport, win }); 24 | emitSetBackgroundColor('red'); 25 | expect(setBackgroundColor).toHaveBeenCalledWith('red'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/server/src/server/nvim/nvim.ts: -------------------------------------------------------------------------------- 1 | // TODO 2 | // import quit from '@main/nvim/features/quit'; 3 | // import windowTitle from '@main/nvim/features/windowTitle'; 4 | // import zoom from '@main/nvim/features/zoom'; 5 | // import windowSize from '@main/nvim/features/windowSize'; 6 | // import focusAutocmd from '@main/nvim/features/focusAutocmd'; 7 | 8 | import initSettings from 'src/server/nvim/settings'; 9 | 10 | import Nvim, { startNvimProcess, ProcNvimTransport, Transport } from '@vvim/nvim'; 11 | 12 | const initNvim = ({ 13 | args, 14 | cwd, 15 | transport, 16 | }: { 17 | args?: string[]; 18 | cwd?: string; 19 | transport: Transport; 20 | }): void => { 21 | const proc = startNvimProcess({ args, cwd }); 22 | const nvimTransport = new ProcNvimTransport(proc, transport); 23 | const nvim = new Nvim(nvimTransport); 24 | 25 | initSettings({ nvim, args, transport }); 26 | 27 | // TODO 28 | // nvim.on('disconnect', () => {}); 29 | }; 30 | 31 | export default initNvim; 32 | -------------------------------------------------------------------------------- /packages/nvim/config/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { merge } = require('webpack-merge'); 3 | 4 | const buildPath = path.resolve(__dirname, './../dist'); 5 | 6 | const commonConfig = { 7 | mode: 'development', 8 | output: { 9 | path: buildPath, 10 | filename: '[name].js', 11 | libraryTarget: 'umd', 12 | globalObject: 'this', 13 | }, 14 | devtool: 'eval-cheap-source-map', 15 | resolve: { 16 | extensions: ['.ts', '.js'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(js|ts)$/, 22 | exclude: /node_modules/, 23 | loader: 'babel-loader', 24 | }, 25 | ], 26 | }, 27 | }; 28 | 29 | const browserConfig = merge(commonConfig, { 30 | target: 'web', 31 | entry: { 32 | browser: './src/browser.ts', 33 | }, 34 | }); 35 | 36 | const nodeConfig = merge(commonConfig, { 37 | target: 'node', 38 | entry: { 39 | index: './src/index.ts', 40 | }, 41 | }); 42 | 43 | module.exports = [browserConfig, nodeConfig]; 44 | -------------------------------------------------------------------------------- /packages/nvim/src/__tests__/process.test.ts: -------------------------------------------------------------------------------- 1 | import { PassThrough } from 'stream'; 2 | import { spawn } from 'child_process'; 3 | import type { ChildProcessWithoutNullStreams } from 'child_process'; 4 | 5 | import startNvimProcess from 'src/process'; 6 | 7 | jest.mock('child_process'); 8 | 9 | const mockedSpawn = jest.mocked(spawn); 10 | 11 | mockedSpawn.mockImplementation( 12 | () => 13 | (({ 14 | stderr: new PassThrough(), 15 | stdout: new PassThrough(), 16 | stdin: new PassThrough(), 17 | } as unknown) as ChildProcessWithoutNullStreams), 18 | ); 19 | 20 | describe('startNvimProcess', () => { 21 | test('init nvim process with spawn', () => { 22 | startNvimProcess(); 23 | expect(mockedSpawn).toHaveBeenCalledWith( 24 | 'nvim', 25 | ['--embed', '--cmd', 'source bin/vv.vim'], 26 | expect.anything(), 27 | ); 28 | }); 29 | 30 | test.todo('TODO: test vvSourceCommand'); 31 | test.todo('TODO: test nvimCommand'); 32 | test.todo('TODO: test env'); 33 | test.todo('TODO: test cwd'); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/lib/getColor.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-bitwise */ 2 | 3 | import memoize from 'lodash/memoize'; 4 | 5 | /** 6 | * Get color by number, for example hex number `0xFF0000` becomes `rgb(255,0,0)` 7 | * @param color Color in number 8 | * @param defaultColor Use default color if color is undefined or -1 9 | */ 10 | export const getColor = (color: number | undefined, defaultColor?: string): string | undefined => { 11 | if (typeof color !== 'number' || color === -1) return defaultColor; 12 | return `rgb(${(color >> 16) & 0xff},${(color >> 8) & 0xff},${color & 0xff})`; 13 | }; 14 | 15 | /** 16 | * Get color number from string, for example `rgb(255,0,0)` becomes `0xFF0000` 17 | * @param color Color in rgb string 18 | */ 19 | export const getColorNum = memoize((color?: string): number | undefined => { 20 | if (color) { 21 | const [r, g, b] = color 22 | .replace(/([^0-9,])/g, '') 23 | .split(',') 24 | .map((s) => parseInt(s, 10)); 25 | return (r << 16) + (g << 8) + b; 26 | } 27 | return undefined; 28 | }); 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Igor Gladkoborodov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/windowTitle.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { BrowserWindow } from 'electron'; 3 | 4 | import type { Nvim } from '@vvim/nvim'; 5 | 6 | const initWindowTitle = ({ nvim, win }: { win: BrowserWindow; nvim: Nvim }): void => { 7 | nvim.on('redraw', (args) => { 8 | args.forEach((arg) => { 9 | if (arg[0] === 'set_title') { 10 | win.setTitle(arg[1][0]); 11 | } 12 | }); 13 | }); 14 | 15 | nvim.on('vv:filename', ([filename]: [string]) => { 16 | if (fs.existsSync(filename)) { 17 | win.setRepresentedFilename(filename); 18 | } 19 | }); 20 | 21 | nvim.command('set title'); // Enable title 22 | nvim.command('set titlestring&'); // Set default titlestring 23 | 24 | // Send current file name to client on buffer enter 25 | nvim.command( 26 | 'autocmd BufEnter * call rpcnotify(get(g:, "vv_channel", 1), "vv:filename", expand("%:p"))', 27 | ); 28 | 29 | // Filename don't fire on startup, doing it manually 30 | nvim.command('call rpcnotify(get(g:, "vv_channel", 1), "vv:filename", expand("%:p"))'); 31 | }; 32 | 33 | export default initWindowTitle; 34 | -------------------------------------------------------------------------------- /packages/electron/config/electron-builder/build.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const { join } = require('path'); 4 | const { readFileSync } = require('fs'); 5 | 6 | const fileAssociations = require('./fileAssociations.json'); 7 | 8 | const path = require.resolve('electron'); 9 | const data = readFileSync(join(path, '..', 'package.json'), { encoding: 'utf-8' }); 10 | const electronVersion = JSON.parse(data).version; 11 | 12 | const build = { 13 | appId: process.env.APPID || 'app.vvim.vv', 14 | productName: 'VV', 15 | extraMetadata: { 16 | name: 'VV', 17 | }, 18 | files: ['build/**/*'], 19 | extraResources: ['bin/**/*', 'src/main/preload.js'], 20 | electronVersion, 21 | directories: { 22 | buildResources: 'assets', 23 | }, 24 | fileAssociations: [ 25 | ...fileAssociations, 26 | { 27 | name: 'Document', 28 | role: 'Editor', 29 | ext: '*', 30 | icon: 'generic.icns', 31 | }, 32 | ], 33 | mac: { 34 | category: 'public.app-category.developer-tools', 35 | target: { 36 | target: 'dir', 37 | arch: 'universal', 38 | }, 39 | }, 40 | }; 41 | 42 | module.exports = build; 43 | -------------------------------------------------------------------------------- /packages/electron/scripts/filetypes.js: -------------------------------------------------------------------------------- 1 | // Generate fileAssociations for electron-builder. 2 | // File types are generated from [Github Linguist](https://github.com/github/linguist) 3 | // languates list. 4 | 5 | const fetch = require('node-fetch'); 6 | const yaml = require('js-yaml'); 7 | const fs = require('fs'); 8 | const path = require('path'); 9 | 10 | const SOURCE_YAML = 11 | 'https://raw.githubusercontent.com/github/linguist/master/lib/linguist/languages.yml'; 12 | 13 | const SAVE_TO = path.join(__dirname, '../config/electron-builder/fileAssociations.json'); 14 | 15 | const filetypes = async () => { 16 | const yamlDoc = await fetch(SOURCE_YAML).then((res) => res.text()); 17 | 18 | const parsed = yaml.safeLoad(yamlDoc); 19 | 20 | const fileAssociations = Object.keys(parsed) 21 | .filter((key) => parsed[key].extensions) 22 | .map((key) => ({ 23 | name: key, 24 | role: 'Editor', 25 | icon: 'generic.icns', 26 | ext: parsed[key].extensions.map((e) => e.replace('.', '')), 27 | })); 28 | 29 | fs.writeFileSync(SAVE_TO, JSON.stringify(fileAssociations, null, 2), { encoding: 'utf-8' }); 30 | }; 31 | 32 | filetypes(); 33 | -------------------------------------------------------------------------------- /packages/server/src/server/transport/websocket.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | import { Server } from 'http'; 3 | 4 | import { Transport, Args } from '@vvim/nvim'; 5 | 6 | import { EventEmitter } from 'events'; 7 | 8 | class WsTransport extends EventEmitter implements Transport { 9 | ws: WebSocket; 10 | 11 | constructor(ws: WebSocket) { 12 | super(); 13 | 14 | this.ws = ws; 15 | 16 | this.ws.on('message', (data: string) => { 17 | try { 18 | const [channel, ...args] = JSON.parse(data); 19 | this.emit(channel, ...args); 20 | } catch (e) { 21 | /* empty */ 22 | } 23 | }); 24 | } 25 | 26 | send(channel: string, ...args: Args) { 27 | this.ws.send(JSON.stringify([channel, ...args])); 28 | } 29 | } 30 | 31 | /** 32 | * Init transport between main and renderer via websocket on server side. 33 | */ 34 | const transport = ({ 35 | server, 36 | onConnect, 37 | }: { 38 | server: Server; 39 | onConnect: (t: Transport) => void; 40 | }): void => { 41 | const wss = new WebSocket.Server({ server }); 42 | 43 | // TODO: handle disconnect 44 | wss.on('connection', (ws) => { 45 | onConnect(new WsTransport(ws)); 46 | }); 47 | }; 48 | 49 | export default transport; 50 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/lib/__tests__/getColor.test.ts: -------------------------------------------------------------------------------- 1 | import { getColor, getColorNum } from 'src/lib/getColor'; 2 | 3 | describe('getColor', () => { 4 | test('0 is black', () => { 5 | expect(getColor(0)).toBe('rgb(0,0,0)'); 6 | }); 7 | 8 | test('0xffffff is white', () => { 9 | expect(getColor(0xffffff)).toBe('rgb(255,255,255)'); 10 | }); 11 | 12 | test('0x333333 is gray', () => { 13 | expect(getColor(0x333333)).toBe('rgb(51,51,51)'); 14 | }); 15 | 16 | test('0x003300 is rgb(0,51,0)', () => { 17 | expect(getColor(0x003300)).toBe('rgb(0,51,0)'); 18 | }); 19 | }); 20 | 21 | describe('getColorNum', () => { 22 | test('rgb(0, 0, 0) is 0', () => { 23 | expect(getColorNum('rgb(0,0,0)')).toBe(0); 24 | }); 25 | 26 | test('rgb(255,255,255) is 0xffffff', () => { 27 | expect(getColorNum('rgb(255,255,255)')).toBe(0xffffff); 28 | }); 29 | 30 | test('rgb(51,51,51) is 0x333333', () => { 31 | expect(getColorNum('rgb(51,51,51)')).toBe(0x333333); 32 | }); 33 | 34 | test('rgb(0,51,0) is 0x00ff00', () => { 35 | expect(getColorNum('rgb(0,51,0)')).toBe(0x003300); 36 | }); 37 | 38 | test('returns undefined for undefined param', () => { 39 | expect(getColorNum()).toBeUndefined(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /.github/workflows/link_typecheck.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Typecheck 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: macos-latest 9 | 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v2 13 | 14 | - name: Read .nvmrc 15 | run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" 16 | id: nvm 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: '${{ steps.nvm.outputs.NVMRC }}' 22 | 23 | - name: Get Yarn cache directory path 24 | id: yarn-cache-dir-path 25 | run: echo "::set-output name=dir::$(yarn cache dir)" 26 | 27 | - name: Cache Yarn 28 | uses: actions/cache@v4 29 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 30 | with: 31 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 32 | key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} 33 | restore-keys: | 34 | yarn-${{ runner.os }}- 35 | 36 | - name: Install Dependencies 37 | run: yarn 38 | 39 | - name: Build Required Packages 40 | run: yarn bootstrap 41 | 42 | - name: Run ESLint 43 | run: yarn lint 44 | if: always() 45 | 46 | - name: Run TypeScript check 47 | run: yarn typecheck 48 | if: always() 49 | -------------------------------------------------------------------------------- /packages/nvim/src/process.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import path from 'path'; 3 | 4 | import debounce from 'lodash/debounce'; 5 | 6 | import { shellEnv, isDev, nvimCommand } from 'src/utils'; 7 | 8 | import type { ChildProcessWithoutNullStreams } from 'child_process'; 9 | 10 | const vvSourceCommand = (appPath?: string) => 11 | appPath ? `source ${path.join(appPath, isDev('./', '../'), 'bin/vv.vim')}` : 'source bin/vv.vim'; 12 | 13 | let nvimProcess; 14 | 15 | const startNvimProcess = ({ 16 | args = [], 17 | cwd, 18 | appPath, 19 | }: { 20 | args?: string[]; 21 | cwd?: string; 22 | appPath?: string; 23 | } = {}): ChildProcessWithoutNullStreams => { 24 | const env = shellEnv(); 25 | 26 | const nvimArgs = ['--embed', '--cmd', vvSourceCommand(appPath), ...args]; 27 | 28 | nvimProcess = spawn(nvimCommand(env), nvimArgs, { cwd, env }); 29 | 30 | // Pipe errors to std output and also send it in console as error. 31 | let errorStr = ''; 32 | nvimProcess.stderr.pipe(process.stdout); 33 | nvimProcess.stderr.on('data', (data) => { 34 | errorStr += data.toString(); 35 | debounce(() => { 36 | if (errorStr) console.error(errorStr); // eslint-disable-line no-console 37 | errorStr = ''; 38 | }, 10)(); 39 | }); 40 | 41 | // nvimProcess.stdout.on('data', (data) => { 42 | // console.log(data.toString()); 43 | // }); 44 | 45 | return nvimProcess; 46 | }; 47 | 48 | export default startNvimProcess; 49 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vvim/server", 3 | "version": "0.0.1", 4 | "description": "VV Server: Run Neovim remotely in browser", 5 | "author": "Igor Gladkoborodov ", 6 | "keywords": [ 7 | "vim", 8 | "neovim", 9 | "client", 10 | "gui", 11 | "electron" 12 | ], 13 | "license": "MIT", 14 | "main": "./build/main.js", 15 | "sideEffects": false, 16 | "scripts": { 17 | "test": "jest", 18 | "clean": "rm -rf dist/*", 19 | "webpack:dev": "webpack --watch --config ./config/webpack.config.js", 20 | "webpack:prod": "webpack --config ./config/webpack.prod.config.js", 21 | "server:dev": "nodemon build/server.js", 22 | "server": "node build/server.js", 23 | "dev": "npm-run-all --parallel webpack:dev server:dev", 24 | "build": "npm-run-all clean webpack:prod", 25 | "start": "yarn server" 26 | }, 27 | "browserslist": [ 28 | "maintained node versions" 29 | ], 30 | "devDependencies": { 31 | "@types/express": "^4.17.11", 32 | "@types/lodash": "^4.14.168", 33 | "@types/node": "^14.14.31", 34 | "@types/ws": "^7.4.0", 35 | "html-webpack-plugin": "^5.6.0", 36 | "node-fetch": "^2.6.7", 37 | "nodemon": "^2.0.7" 38 | }, 39 | "dependencies": { 40 | "@vvim/browser-renderer": "0.0.1", 41 | "@vvim/nvim": "0.0.1", 42 | "express": "^4.17.1", 43 | "lodash": "^4.17.21", 44 | "semver": "^7.5.2", 45 | "ws": "^7.4.6" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/preloaded/electron.ts: -------------------------------------------------------------------------------- 1 | export interface PreloadedIpcRenderer { 2 | /** 3 | * Listens to `channel`, when a new message arrives `listener` would be called with 4 | * `listener(args...)`. 5 | */ 6 | on(channel: string, listener: (...args: any[]) => void): this; 7 | /** 8 | * Adds a one time `listener` function for the event. This `listener` is invoked 9 | * only the next time a message is sent to `channel`, after which it is removed. 10 | */ 11 | removeListener(channel: string, listener: (...args: any[]) => void): this; 12 | /** 13 | * Send an asynchronous message to the main process via `channel`, along with 14 | * arguments. Arguments will be serialized with the Structured Clone Algorithm, 15 | * just like `window.postMessage`, so prototype chains will not be included. 16 | * Sending Functions, Promises, Symbols, WeakMaps, or WeakSets will throw an 17 | * exception. 18 | * 19 | * > **NOTE**: Sending non-standard JavaScript types such as DOM objects or special 20 | * Electron objects is deprecated, and will begin throwing an exception starting 21 | * with Electron 9. 22 | * 23 | * The main process handles it by listening for `channel` with the `ipcMain` 24 | * module. 25 | */ 26 | send(channel: string, ...args: any[]): void; 27 | } 28 | 29 | declare global { 30 | interface Window { 31 | electron: { 32 | ipcRenderer: PreloadedIpcRenderer; 33 | }; 34 | } 35 | } 36 | 37 | export const { ipcRenderer } = window.electron || {}; 38 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/transport/ipc.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import memoize from 'lodash/memoize'; 3 | 4 | import { ipcRenderer } from 'src/preloaded/electron'; 5 | import type { PreloadedIpcRenderer } from 'src/preloaded/electron'; 6 | 7 | import type { Transport, Args } from '@vvim/nvim'; 8 | 9 | /** 10 | * Init transport between main and renderer via Electron ipcRenderer. 11 | */ 12 | class IpcRendererTransport extends EventEmitter implements Transport { 13 | private ipc: PreloadedIpcRenderer; 14 | 15 | constructor(ipc = ipcRenderer) { 16 | super(); 17 | 18 | this.ipc = ipc; 19 | 20 | this.on('newListener', (eventName: string) => { 21 | if ( 22 | !this.listenerCount(eventName) && 23 | !['newListener', 'removeListener'].includes(eventName) 24 | ) { 25 | this.ipc.on(eventName, this.handleEvent(eventName)); 26 | } 27 | }); 28 | 29 | this.on('removeListener', (eventName: string) => { 30 | if ( 31 | !this.listenerCount(eventName) && 32 | !['newListener', 'removeListener'].includes(eventName) 33 | ) { 34 | this.ipc.removeListener(eventName, this.handleEvent(eventName)); 35 | } 36 | }); 37 | } 38 | 39 | handleEvent = memoize((eventName: string) => (...args: Args): void => { 40 | this.emit(eventName, ...args); 41 | }); 42 | 43 | send(channel: string, ...params: Args): void { 44 | this.ipc.send(channel, ...params); 45 | } 46 | } 47 | 48 | export default IpcRendererTransport; 49 | -------------------------------------------------------------------------------- /packages/electron/bin/openInProject.vim: -------------------------------------------------------------------------------- 1 | " Opens file respecting switchbuf setting. 2 | function! VVopenInProject(filename, ...) 3 | " Take switch override from second parameter or from VV settings. 4 | let l:switchbuf_override = get(a:, 1, VVsettingValue('openInProject')) 5 | 6 | silent call VVopenInProjectLoud(a:filename, l:switchbuf_override) 7 | endfunction 8 | 9 | function! VVopenInProjectLoud(fileName, switchbuf_override) 10 | " Temporary override switchbuf if we have custom openInProject setting. 11 | if type(a:switchbuf_override) == v:t_string && a:switchbuf_override != '0' && a:switchbuf_override != '1' 12 | let l:original_switchbuf = &switchbuf 13 | let &switchbuf = a:switchbuf_override 14 | endif 15 | 16 | " Create temporary quickfix list with file we want to open 17 | if (!exists('g:vvOpenInProjectQfId') || getqflist({ 'id': 0 }).id != g:vvOpenInProjectQfId) 18 | call setqflist([], ' ', { 'title': 'VV Temporary Quickfix' }) 19 | let g:vvOpenInProjectQfId = getqflist({ 'id': 0 }).id 20 | end 21 | 22 | " Add file to list and open it. It will be opened according to current switchbuf 23 | " setting. 24 | call setqflist([], 'r', { 'id': g:vvOpenInProjectQfId, 'items': [{ 'filename': a:fileName }] }) 25 | cc! 1 26 | 27 | " Switch to previous quickfix list if there are other lists. 28 | if getqflist({'nr' : 0 }).nr > 1 29 | colder 30 | end 31 | 32 | " Rollback to original switchbuf option if needed. 33 | if exists('l:original_switchbuf') 34 | let &switchbuf = l:original_switchbuf 35 | endif 36 | endfunction 37 | 38 | function! VVprojectRoot() 39 | return getcwd() 40 | endfunction 41 | -------------------------------------------------------------------------------- /packages/electron/src/main/checkNeovim.ts: -------------------------------------------------------------------------------- 1 | import { app, dialog, shell } from 'electron'; 2 | import semver from 'semver'; 3 | 4 | import { nvimVersion } from '@vvim/nvim'; 5 | 6 | const REQUIRED_VERSION = '0.4.0'; 7 | 8 | const checkNeovim = (): void => { 9 | const version = nvimVersion(); 10 | if (!version) { 11 | const result = dialog.showMessageBoxSync({ 12 | message: 'Neovim is not installed', 13 | detail: `VV requires Neovim. You can install it via Homebrew: 14 | brew install neovim 15 | 16 | Or you can find Neovim installation instructions here: 17 | https://github.com/neovim/neovim/wiki/Installing-Neovim 18 | `, 19 | defaultId: 0, 20 | buttons: ['Open Installation Instructions', 'Close'], 21 | }); 22 | if (result === 0) { 23 | shell.openExternal('https://github.com/neovim/neovim/wiki/Installing-Neovim'); 24 | } 25 | app.exit(); 26 | } else if (semver.lt(version, REQUIRED_VERSION)) { 27 | const result = dialog.showMessageBoxSync({ 28 | message: 'Neovim is outdated', 29 | detail: `VV requires Neovim version ${REQUIRED_VERSION} and later. 30 | You have ${version}. 31 | 32 | If you installed Neovim via Homebrew, please run: 33 | brew upgrade neovim 34 | 35 | Otherwise please check installation instructions here: 36 | https://github.com/neovim/neovim/wiki/Installing-Neovim 37 | `, 38 | defaultId: 0, 39 | buttons: ['Open Installation Instructions', 'Close'], 40 | }); 41 | if (result === 0) { 42 | shell.openExternal('https://github.com/neovim/neovim/wiki/Installing-Neovim'); 43 | } 44 | app.exit(); 45 | } 46 | }; 47 | 48 | export default checkNeovim; 49 | -------------------------------------------------------------------------------- /packages/electron/src/main/installCli.ts: -------------------------------------------------------------------------------- 1 | import { dialog } from 'electron'; 2 | import { execSync } from 'child_process'; 3 | 4 | import which from 'src/main/lib/which'; 5 | 6 | const showInstallCliDialog = () => 7 | dialog.showMessageBoxSync({ 8 | message: 'Command line launcher', 9 | detail: `With command line launcher you can run VV from terminal: 10 | $ vv [filename] 11 | 12 | Do you wish to install it? It will be placed 13 | to /usr/local/bin. 14 | `, 15 | cancelId: 1, 16 | defaultId: 0, 17 | buttons: ['Install', 'Cancel'], 18 | }); 19 | 20 | const showCliInstalledDialog = (message: string, path: string) => 21 | dialog.showMessageBox({ 22 | message, 23 | detail: `Command line launcher installed at ${path}. You can run VV from terminal by typing: 24 | $ vv [filename] 25 | `, 26 | defaultId: 0, 27 | buttons: ['Ok'], 28 | }); 29 | 30 | const showErrorDialog = (error: Error) => { 31 | dialog.showMessageBox({ 32 | message: 'Error', 33 | detail: error.message, 34 | defaultId: 0, 35 | buttons: ['Ok'], 36 | }); 37 | }; 38 | 39 | const installCli = (binPath: string) => (): void => { 40 | let path = which('vv'); 41 | if (path && path.indexOf('VV.app/Contents/MacOS/vv') === -1) { 42 | path = path.replace('\n', ''); 43 | showCliInstalledDialog('Command Line Launcher', path); 44 | } else { 45 | const response = showInstallCliDialog(); 46 | if (response === 0) { 47 | try { 48 | execSync(`ln -sf ${binPath} /usr/local/bin/`); 49 | } catch (error) { 50 | showErrorDialog(error); 51 | return; 52 | } 53 | showCliInstalledDialog('Done', '/usr/local/bin/vv'); 54 | } 55 | } 56 | }; 57 | 58 | export default installCli; 59 | -------------------------------------------------------------------------------- /packages/nvim/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vvim/nvim", 3 | "version": "0.0.1", 4 | "description": "Lightweight transport agnostic Neovim API client to be used in other @vvim packages", 5 | "author": "Igor Gladkoborodov ", 6 | "keywords": [ 7 | "vim", 8 | "neovim", 9 | "client", 10 | "api" 11 | ], 12 | "homepage": "https://github.com/vv-vim/vv#readme", 13 | "license": "MIT", 14 | "main": "dist/index.js", 15 | "browser": "dist/browser.js", 16 | "sideEffects": false, 17 | "scripts": { 18 | "test": "jest", 19 | "clean": "rm -rf dist/*", 20 | "build:types": "tsc -p tsconfig.declaration.json", 21 | "build:dev": "webpack --config ./config/webpack.config.js", 22 | "build:prod": "webpack --config ./config/webpack.prod.config.js", 23 | "build": "npm-run-all clean build:types build:prod", 24 | "dev": "npm-run-all --parallel \"build:types --watch\" \"build:dev --watch\"" 25 | }, 26 | "publishConfig": { 27 | "registry": "https://registry.yarnpkg.com" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/vv-vim/vv.git" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/vv-vim/vv/issues" 35 | }, 36 | "browserslist": [ 37 | "defaults", 38 | "last 2 electron versions", 39 | "maintained node versions" 40 | ], 41 | "devDependencies": { 42 | "@types/express": "^4.17.11", 43 | "@types/lodash": "^4.14.168", 44 | "@types/msgpack-lite": "^0.1.7", 45 | "@types/node": "^14.14.31", 46 | "@types/ws": "^7.4.0", 47 | "strict-event-emitter-types": "^2.0.0" 48 | }, 49 | "dependencies": { 50 | "lodash": "^4.17.21", 51 | "msgpack-lite": "^0.1.26", 52 | "ws": "^7.4.6" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/nvim.ts: -------------------------------------------------------------------------------- 1 | import { app } from 'electron'; 2 | 3 | import Nvim, { startNvimProcess, ProcNvimTransport, Transport } from '@vvim/nvim'; 4 | 5 | import { setNvimByWindow } from 'src/main/nvim/nvimByWindow'; 6 | 7 | import quit from 'src/main/nvim/features/quit'; 8 | import windowTitle from 'src/main/nvim/features/windowTitle'; 9 | import zoom from 'src/main/nvim/features/zoom'; 10 | import reloadChanged from 'src/main/nvim/features/reloadChanged'; 11 | import windowSize from 'src/main/nvim/features/windowSize'; 12 | import focusAutocmd from 'src/main/nvim/features/focusAutocmd'; 13 | import backgroundColor from 'src/main/nvim/features/backrdoundColor'; 14 | 15 | import initSettings from 'src/main/nvim/settings'; 16 | 17 | import type { BrowserWindow } from 'electron'; 18 | 19 | const initNvim = ({ 20 | args, 21 | cwd, 22 | win, 23 | transport, 24 | }: { 25 | args: string[]; 26 | cwd: string; 27 | win: BrowserWindow; 28 | transport: Transport; 29 | }): void => { 30 | const proc = startNvimProcess({ args, cwd, appPath: app.getAppPath() }); 31 | const nvimTransport = new ProcNvimTransport(proc, transport); 32 | const nvim = new Nvim(nvimTransport); 33 | 34 | setNvimByWindow(win, nvim); 35 | 36 | initSettings({ win, nvim, args, transport }); 37 | windowSize({ win, transport }); 38 | quit({ win, nvim }); 39 | windowTitle({ win, nvim }); 40 | zoom({ win }); 41 | reloadChanged({ win, nvim }); 42 | focusAutocmd({ win, nvim }); 43 | backgroundColor({ win, transport }); 44 | 45 | nvim 46 | .request('nvim_get_api_info') 47 | .then(([channelId]: [string]) => nvim.setVar('vv_channel', channelId)); 48 | 49 | nvim.on('vv:vim_enter', () => { 50 | win.show(); 51 | }); 52 | }; 53 | 54 | export default initNvim; 55 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: macos-latest 12 | 13 | steps: 14 | - name: Checkout Repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Read .nvmrc 18 | run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" 19 | id: nvm 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '${{ steps.nvm.outputs.NVMRC }}' 25 | 26 | - name: Install Nvim 27 | run: brew install nvim 28 | 29 | - name: Get Yarn cache directory path 30 | id: yarn-cache-dir-path 31 | run: echo "::set-output name=dir::$(yarn cache dir)" 32 | 33 | - name: Cache Yarn 34 | uses: actions/cache@v4 35 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 36 | with: 37 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 38 | key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} 39 | restore-keys: | 40 | yarn-${{ runner.os }}- 41 | 42 | - name: Install Dependencies 43 | run: yarn 44 | 45 | - name: Build Required Packages 46 | run: yarn bootstrap 47 | 48 | - name: Run Tests 49 | run: yarn test --reporters="default" --reporters="jest-github-actions-reporter" --coverage 50 | 51 | - name: Upload snapshot diffs 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: failed-image-snapshots 55 | path: packages/**/__diff_output__/* 56 | retention-days: 5 57 | if: failure() 58 | 59 | - name: Upload coverage to Codecov 60 | uses: codecov/codecov-action@v5 61 | -------------------------------------------------------------------------------- /packages/nvim/src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | import { shellEnv, nvimVersion, resetCache } from 'src/utils'; 4 | 5 | jest.mock('child_process'); 6 | 7 | const mockedExecSync = jest.mocked((execSync as unknown) as () => string); 8 | 9 | describe('utils', () => { 10 | beforeEach(() => { 11 | resetCache(); 12 | }); 13 | 14 | describe('shellEnv', () => { 15 | const fakeProc = (env = {}) => 16 | ({ 17 | env, 18 | } as NodeJS.Process); 19 | 20 | test('returns env from bash', () => { 21 | mockedExecSync.mockReturnValue(`key1=val1\nkey2=val2`); 22 | expect(shellEnv(fakeProc())).toEqual({ key1: 'val1', key2: 'val2' }); 23 | expect(mockedExecSync).toHaveBeenCalledWith('/bin/bash -ilc env', { encoding: 'utf-8' }); 24 | }); 25 | 26 | test('returns original env if it has SHLVL', () => { 27 | mockedExecSync.mockReturnValue(`key1=val1\nkey2=val2`); 28 | expect(shellEnv(fakeProc({ SHLVL: true, key: 'val' }))).toEqual({ 29 | SHLVL: true, 30 | key: 'val', 31 | }); 32 | }); 33 | 34 | test('add default path if something happens', () => { 35 | mockedExecSync.mockImplementationOnce(() => { 36 | throw new Error(); 37 | }); 38 | expect(shellEnv(fakeProc({ PATH: 'some/path', key: 'val' }))).toEqual({ 39 | PATH: '/usr/local/bin:/opt/homebrew/bin:some/path', 40 | key: 'val', 41 | }); 42 | }); 43 | }); 44 | 45 | describe('nvimVersion', () => { 46 | test('find version string from `nvim --version`', () => { 47 | mockedExecSync.mockReturnValue(`Something 48 | NVIM v1.2.3 49 | Something else`); 50 | expect(nvimVersion()).toBe('1.2.3'); 51 | expect(mockedExecSync).toHaveBeenCalledWith('nvim --version', expect.any(Object)); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/browser-renderer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vvim/browser-renderer", 3 | "version": "0.0.1", 4 | "description": "VV Browser Renderer", 5 | "author": "Igor Gladkoborodov ", 6 | "keywords": [ 7 | "vim", 8 | "neovim", 9 | "client", 10 | "gui", 11 | "renderer", 12 | "browser", 13 | "webgl" 14 | ], 15 | "homepage": "https://github.com/vv-vim/vv#readme", 16 | "license": "MIT", 17 | "main": "dist/index.js", 18 | "sideEffects": false, 19 | "scripts": { 20 | "test": "jest", 21 | "clean": "rm -rf dist/*", 22 | "build:types": "tsc -p tsconfig.declaration.json", 23 | "build:dev": "webpack --config ./config/webpack.config.js", 24 | "build:prod": "webpack --config ./config/webpack.prod.config.js", 25 | "build": "npm-run-all clean build:types build:prod", 26 | "dev": "npm-run-all --parallel \"build:types --watch\" \"build:dev --watch\"" 27 | }, 28 | "publishConfig": { 29 | "registry": "https://registry.yarnpkg.com" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/vv-vim/vv.git" 34 | }, 35 | "bugs": { 36 | "url": "https://github.com/vv-vim/vv/issues" 37 | }, 38 | "browserslist": [ 39 | "defaults", 40 | "last 2 electron versions" 41 | ], 42 | "devDependencies": { 43 | "@types/express": "^4.17.11", 44 | "@types/jest-dev-server": "^5.0.3", 45 | "@types/jest-image-snapshot": "^6.4.0", 46 | "@types/lodash": "^4.14.168", 47 | "@types/node": "^16.0.0", 48 | "@types/ws": "^7.4.0", 49 | "jest-dev-server": "^11.0.0", 50 | "jest-environment-jsdom": "^30.1.1", 51 | "jest-image-snapshot": "^6.5.1", 52 | "jsdom": "^26.1.0", 53 | "puppeteer": "^24.17.1" 54 | }, 55 | "dependencies": { 56 | "@vvim/nvim": "0.0.1", 57 | "lodash": "^4.17.21", 58 | "ws": "^7.4.6" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/zoom.ts: -------------------------------------------------------------------------------- 1 | import { app, MenuItemConstructorOptions, BrowserWindow } from 'electron'; 2 | import { getNvimByWindow } from 'src/main/nvim/nvimByWindow'; 3 | 4 | const nvimChangeZoom = (win: BrowserWindow, level: number) => { 5 | const nvim = getNvimByWindow(win); 6 | if (nvim) { 7 | nvim.command(`VVset fontsize${level > 0 ? '+' : '-'}=${Math.abs(level)}`); 8 | } 9 | }; 10 | 11 | const disableActualSizeItem = (win: BrowserWindow) => { 12 | const actualSize = app.applicationMenu?.getMenuItemById('actualSize'); 13 | if (actualSize) { 14 | // @ts-expect-error TODO: window custom params 15 | actualSize.enabled = win.zoomLevel !== 0; 16 | } 17 | }; 18 | 19 | export const zoomInMenuItem: MenuItemConstructorOptions['click'] = (_item, win) => { 20 | if (win) { 21 | // @ts-expect-error TODO: window custom params 22 | win.zoomLevel += 1; // eslint-disable-line no-param-reassign 23 | nvimChangeZoom(win, 1); 24 | disableActualSizeItem(win); 25 | } 26 | }; 27 | 28 | export const zoomOutMenuItem: MenuItemConstructorOptions['click'] = (_item, win) => { 29 | if (win) { 30 | // @ts-expect-error TODO: window custom params 31 | win.zoomLevel -= 1; // eslint-disable-line no-param-reassign 32 | nvimChangeZoom(win, -1); 33 | disableActualSizeItem(win); 34 | } 35 | }; 36 | 37 | export const actualSizeMenuItem: MenuItemConstructorOptions['click'] = (_item, win) => { 38 | if (win) { 39 | // @ts-expect-error TODO: window custom params 40 | nvimChangeZoom(win, -win.zoomLevel); 41 | // @ts-expect-error TODO: window custom params 42 | win.zoomLevel = 0; // eslint-disable-line no-param-reassign 43 | disableActualSizeItem(win); 44 | } 45 | }; 46 | 47 | const initZoom = ({ win }: { win: BrowserWindow }): void => { 48 | win.on('focus', () => { 49 | disableActualSizeItem(win); 50 | }); 51 | }; 52 | 53 | export default initZoom; 54 | -------------------------------------------------------------------------------- /packages/electron/src/main/transport/ipc.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from 'electron'; 2 | import { EventEmitter } from 'events'; 3 | import memoize from 'lodash/memoize'; 4 | 5 | import { Transport, Args } from '@vvim/nvim'; 6 | 7 | /** 8 | * Init transport between main and renderer to be used for main side. 9 | */ 10 | class IpcTransport extends EventEmitter implements Transport { 11 | win: Electron.BrowserWindow; 12 | 13 | closed = false; 14 | 15 | ipc: Electron.IpcMain; 16 | 17 | constructor(win: Electron.BrowserWindow, ipc = ipcMain) { 18 | super(); 19 | 20 | this.win = win; 21 | 22 | this.ipc = ipc; 23 | 24 | win.on('closed', () => { 25 | this.closed = true; 26 | }); 27 | 28 | this.on('newListener', (eventName: string) => { 29 | if ( 30 | !this.listenerCount(eventName) && 31 | !['newListener', 'removeListener'].includes(eventName) 32 | ) { 33 | this.ipc.on(eventName, this.handleEvent(eventName)); 34 | this.win.on('closed', () => { 35 | this.ipc.removeListener(eventName, this.handleEvent(eventName)); 36 | }); 37 | } 38 | }); 39 | 40 | this.on('removeListener', (eventName: string) => { 41 | if ( 42 | !this.listenerCount(eventName) && 43 | !['newListener', 'removeListener'].includes(eventName) 44 | ) { 45 | this.ipc.removeListener(eventName, this.handleEvent(eventName)); 46 | } 47 | }); 48 | } 49 | 50 | handleEvent = memoize( 51 | (eventName: string) => (event: Electron.IpcMainEvent, ...args: Args): void => { 52 | const { 53 | sender: { id }, 54 | } = event; 55 | if (id === this.win.id) { 56 | this.emit(eventName, ...args); 57 | } 58 | }, 59 | ); 60 | 61 | send(channel: string, ...args: any[]): void { 62 | if (!this.closed) { 63 | this.win.webContents.send(channel, ...args); 64 | } 65 | } 66 | } 67 | 68 | export default IpcTransport; 69 | -------------------------------------------------------------------------------- /packages/nvim/src/ProcNvimTransport.ts: -------------------------------------------------------------------------------- 1 | import { createDecodeStream, encode } from 'msgpack-lite'; 2 | import { EventEmitter } from 'events'; 3 | 4 | import type { ChildProcessWithoutNullStreams } from 'child_process'; 5 | import type { DecodeStream } from 'msgpack-lite'; 6 | import type { Transport, MessageType } from 'src/types'; 7 | 8 | /** 9 | * Transport that communicates directly with nvim process. 10 | * It also used as to communicate nvim api with remote transport. 11 | */ 12 | class ProcNvimTransport extends EventEmitter implements Transport { 13 | private msgpackIn: DecodeStream; 14 | 15 | private proc: ChildProcessWithoutNullStreams; 16 | 17 | constructor(proc: ChildProcessWithoutNullStreams, remoteTransport?: Transport) { 18 | super(); 19 | 20 | this.proc = proc; 21 | 22 | const decodeStream = createDecodeStream(); 23 | this.msgpackIn = this.proc.stdout.pipe(decodeStream); 24 | 25 | this.proc.on('close', () => this.emit('nvim:close')); 26 | this.msgpackIn.on('data', (message: MessageType) => this.emit('nvim:data', message)); 27 | 28 | if (remoteTransport) { 29 | this.attachRemoteTransport(remoteTransport); 30 | } 31 | } 32 | 33 | attachRemoteTransport(remoteTransport: Transport): void { 34 | remoteTransport.on('nvim:write', (...args: [number, string, string[]]) => this.write(...args)); 35 | this.on('nvim:close', () => remoteTransport.send('nvim:close')); 36 | this.on('nvim:data', (data: MessageType) => remoteTransport.send('nvim:data', data)); 37 | } 38 | 39 | private write(id: number, command: string, params: string[]): void { 40 | if (this.proc.stdin.writable) { 41 | this.proc.stdin.write(encode([0, id, command, params])); 42 | } 43 | } 44 | 45 | send(channel: string, id: number, command: string, params: string[]): void { 46 | if (channel === 'nvim:write') { 47 | this.write(id, command, params); 48 | } 49 | } 50 | } 51 | 52 | export default ProcNvimTransport; 53 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/__tests__/renderer.test.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import initRenderer from 'src/renderer'; 3 | 4 | import Nvim from '@vvim/nvim'; 5 | import initScreen from 'src/screen'; 6 | import initKeyboard from 'src/input/keyboard'; 7 | import initMouse from 'src/input/mouse'; 8 | import hideMouseCursor from 'src/features/hideMouseCursor'; 9 | 10 | const mockTransport = new EventEmitter(); 11 | jest.mock('src/transport/transport', () => { 12 | return jest.fn().mockImplementation(() => mockTransport); 13 | }); 14 | 15 | jest.mock('@vvim/nvim'); 16 | jest.mock('src/screen', () => jest.fn(() => 'fakeScreen')); 17 | jest.mock('src/input/keyboard', () => jest.fn()); 18 | jest.mock('src/input/mouse', () => jest.fn()); 19 | jest.mock('src/features/hideMouseCursor', () => jest.fn()); 20 | 21 | describe('renderer', () => { 22 | const mockedNvim = (Nvim as unknown) as jest.Mock; 23 | 24 | beforeEach(() => { 25 | mockTransport.removeAllListeners(); 26 | initRenderer(); 27 | }); 28 | 29 | test('init screen', () => { 30 | mockTransport.emit('initRenderer', 'settings'); 31 | expect(initScreen).toHaveBeenCalledWith({ 32 | nvim: mockedNvim.mock.instances[0], 33 | settings: 'settings', 34 | transport: mockTransport, 35 | }); 36 | }); 37 | 38 | test('init nvim', () => { 39 | mockTransport.emit('initRenderer', 'settings'); 40 | expect(Nvim).toHaveBeenCalledWith(mockTransport, true); 41 | }); 42 | 43 | test('init keyboard', () => { 44 | mockTransport.emit('initRenderer', 'settings'); 45 | expect(initKeyboard).toHaveBeenCalledWith({ 46 | nvim: mockedNvim.mock.instances[0], 47 | screen: 'fakeScreen', 48 | }); 49 | }); 50 | 51 | test('init mouse', () => { 52 | mockTransport.emit('initRenderer', 'settings'); 53 | expect(initMouse).toHaveBeenCalledWith({ 54 | nvim: mockedNvim.mock.instances[0], 55 | screen: 'fakeScreen', 56 | }); 57 | }); 58 | 59 | test('init hideMouseCursor', () => { 60 | mockTransport.emit('initRenderer', 'settings'); 61 | expect(hideMouseCursor).toHaveBeenCalledWith(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/__tests__/windowSize.test.ts: -------------------------------------------------------------------------------- 1 | import initWindowSize from 'src/main/nvim/features/windowSize'; 2 | 3 | import { EventEmitter } from 'events'; 4 | import type { Transport } from '@vvim/nvim'; 5 | import type { BrowserWindow } from 'electron'; 6 | 7 | describe('initWindowSize', () => { 8 | describe('set-screen-width', () => { 9 | const setContentSize = jest.fn(); 10 | const getContentSize = jest.fn(); 11 | const send = jest.fn(); 12 | 13 | // TODO: Come up with the better way to mock BrowserWindow 14 | const win = ({ 15 | setContentSize, 16 | getContentSize, 17 | getBounds: () => { 18 | /* empty */ 19 | }, 20 | setBounds: () => { 21 | /* empty */ 22 | }, 23 | isFullScreen: () => false, 24 | setSimpleFullScreen: () => { 25 | /* empty */ 26 | }, 27 | webContents: { 28 | focus: () => { 29 | /* empty */ 30 | }, 31 | }, 32 | } as unknown) as BrowserWindow; 33 | 34 | let transport: Transport; 35 | 36 | beforeEach(() => { 37 | jest.clearAllMocks(); 38 | getContentSize.mockReturnValue([100, 200]); 39 | transport = Object.assign(new EventEmitter(), { 40 | send, 41 | }); 42 | }); 43 | 44 | test('set window size on set-screen-width', () => { 45 | initWindowSize({ transport, win }); 46 | transport.emit('set-screen-width', 150); 47 | expect(setContentSize).toHaveBeenCalledWith(150, 200); 48 | }); 49 | 50 | test('set window size on set-screen-height', () => { 51 | initWindowSize({ transport, win }); 52 | getContentSize.mockReturnValueOnce([100, 200]).mockReturnValueOnce([100, 250]); 53 | transport.emit('set-screen-height', 250); 54 | expect(setContentSize).toHaveBeenCalledWith(100, 250); 55 | expect(send).not.toHaveBeenCalledWith('force-resize'); 56 | }); 57 | 58 | test('send force-resize if window height is the same after resize', () => { 59 | initWindowSize({ transport, win }); 60 | getContentSize.mockReturnValueOnce([100, 200]).mockReturnValueOnce([100, 200]); 61 | transport.emit('set-screen-height', 250); 62 | expect(send).toHaveBeenCalledWith('force-resize'); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/transport/__tests__/websocket.test.ts: -------------------------------------------------------------------------------- 1 | import WebSocketTransport from 'src/transport/websocket'; 2 | 3 | describe('websocket transport', () => { 4 | const OriginalWebSocket = WebSocket; 5 | 6 | const constructor = jest.fn(); 7 | const send = jest.fn(); 8 | let onmessage: (x: { data: string }) => void; 9 | const listener = jest.fn(); 10 | 11 | class MockWebSocket { 12 | constructor(...args: any[]) { 13 | constructor(...args); 14 | } 15 | 16 | // eslint-disable-next-line class-methods-use-this 17 | send(...args: any[]) { 18 | send(...args); 19 | } 20 | 21 | // eslint-disable-next-line class-methods-use-this 22 | set onmessage(value: (x: { data: string }) => void) { 23 | onmessage = value; 24 | } 25 | } 26 | 27 | let transport: WebSocketTransport; 28 | 29 | beforeEach(() => { 30 | // @ts-expect-error Mocking WebSocket 31 | global.WebSocket = MockWebSocket; 32 | 33 | transport = new WebSocketTransport(); 34 | }); 35 | 36 | afterEach(() => { 37 | global.WebSocket = OriginalWebSocket; 38 | }); 39 | 40 | test('connects to websocket', () => { 41 | expect(constructor).toHaveBeenCalledWith('ws://localhost'); 42 | }); 43 | 44 | test('send method sends channel and args to websocket', () => { 45 | transport.send('channel', 'arg1', 'arg2'); 46 | expect(send).toHaveBeenCalledWith('["channel","arg1","arg2"]'); 47 | }); 48 | 49 | test('sent message is JSON stringified', () => { 50 | transport.send('channel', { complex: { object: true } }); 51 | expect(send).toHaveBeenCalledWith('["channel",{"complex":{"object":true}}]'); 52 | }); 53 | 54 | test('receive message if you subscribe to chanel', () => { 55 | transport.on('channel', listener); 56 | 57 | onmessage({ data: JSON.stringify(['channel', ['arg1', 'arg2']]) }); 58 | expect(listener).toHaveBeenCalledWith(['arg1', 'arg2']); 59 | 60 | onmessage({ data: JSON.stringify(['channel', ['arg3']]) }); 61 | expect(listener).toHaveBeenCalledWith(['arg3']); 62 | }); 63 | 64 | test("don't receive messages for channels you did not subscribe", () => { 65 | transport.on('channel', listener); 66 | onmessage({ data: JSON.stringify(['other-channel', ['arg1', 'arg2']]) }); 67 | expect(listener).not.toHaveBeenCalled(); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /packages/electron/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vvim/electron", 3 | "description": "Neovim GUI Client", 4 | "author": "Igor Gladkoborodov ", 5 | "version": "2.6.2", 6 | "private": true, 7 | "keywords": [ 8 | "vim", 9 | "neovim", 10 | "client", 11 | "gui", 12 | "electron" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com:vv-vim/vv.git" 17 | }, 18 | "license": "MIT", 19 | "main": "./build/main.js", 20 | "sideEffects": false, 21 | "scripts": { 22 | "test": "jest", 23 | "clean": "rm -rf dist/*", 24 | "webpack:dev": "webpack --watch --config ./config/webpack.config.js", 25 | "webpack:prod": "webpack --config ./config/webpack.prod.config.js", 26 | "build:local": "yarn webpack:prod; electron-builder -c.mac.identity=null -c.extraMetadata.main=build/main.js --config config/electron-builder/build.js", 27 | "build:release": "electron-builder -c.extraMetadata.main=build/main.js --config config/electron-builder/release.js --publish always", 28 | "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", 29 | "build": "npm-run-all clean webpack:prod build:local build:link", 30 | "release:open-github": "open https://github.com/vv-vim/vv/releases", 31 | "release": "npm-run-all clean webpack:prod build:release release:open-github", 32 | "filetypes": "node scripts/filetypes.js", 33 | "dev": "yarn webpack:dev", 34 | "start": "electron ." 35 | }, 36 | "browserslist": [ 37 | "chrome 122", 38 | "node 20" 39 | ], 40 | "devDependencies": { 41 | "@types/jest": "^26.0.20", 42 | "@types/lodash": "^4.14.168", 43 | "@types/node": "^16.0.0", 44 | "chalk": "^4.1.0", 45 | "dotenv": "^8.2.0", 46 | "electron": "^29", 47 | "electron-builder": "^24.13.3", 48 | "html-webpack-plugin": "^5.6.0", 49 | "js-yaml": "^3.14.0", 50 | "node-fetch": "^2.6.7" 51 | }, 52 | "dependencies": { 53 | "@vvim/browser-renderer": "0.0.1", 54 | "@vvim/nvim": "0.0.1", 55 | "electron-store": "^7.0.2", 56 | "electron-updater": "^4.3.5", 57 | "emoji-regex": "^10.3.0", 58 | "html2plaintext": "^2.1.2", 59 | "lodash": "^4.17.21", 60 | "semver": "^7.5.2" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/quit.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handle close window routine 3 | */ 4 | 5 | import { dialog, app, BrowserWindow } from 'electron'; 6 | import { deleteNvimByWindow } from 'src/main/nvim/nvimByWindow'; 7 | 8 | import type Nvim from '@vvim/nvim'; 9 | 10 | /** 11 | * If we want to quit app after closing window, shouldQuit is true. 12 | * This function is used in 'before-quit' event to switch to close app mode. 13 | */ 14 | let shouldQuit = false; 15 | 16 | export const setShouldQuit = (newShouldQuit: boolean): void => { 17 | shouldQuit = newShouldQuit; 18 | }; 19 | 20 | /** 21 | * Show Save All dialog if there are any unsaved buffers. 22 | * Cancel quit on cancel. 23 | */ 24 | const showCloseDialog = async ({ nvim, win }: { nvim: Nvim; win: BrowserWindow }) => { 25 | const unsavedBuffers = await nvim.callFunction>('VVunsavedBuffers', []); 26 | if (unsavedBuffers.length === 0) { 27 | nvim.command('qa'); 28 | } else { 29 | win.focus(); 30 | const { response } = await dialog.showMessageBox(win, { 31 | message: `You have ${unsavedBuffers.length} unsaved buffers. Do you want to save them?`, 32 | detail: `${unsavedBuffers.map((b) => b.name).join('\n')}\n`, 33 | cancelId: 2, 34 | defaultId: 0, 35 | buttons: ['Save All', 'Discard All', 'Cancel'], 36 | }); 37 | if (response === 0) { 38 | await nvim.command('xa'); // Save All 39 | } else if (response === 1) { 40 | await nvim.command('qa!'); // Discard All 41 | } 42 | setShouldQuit(false); 43 | } 44 | }; 45 | 46 | const initQuit = ({ win, nvim }: { nvim: Nvim; win: BrowserWindow }): void => { 47 | let isConnected = true; 48 | 49 | // Close window if nvim process is closed. 50 | nvim.on('close', () => { 51 | // Disable fullscreen before close, otherwise it it will keep menu bar hidden after window 52 | // is closed. 53 | win.hide(); 54 | win.setSimpleFullScreen(false); 55 | 56 | isConnected = false; 57 | deleteNvimByWindow(win); 58 | win.close(); 59 | }); 60 | 61 | // If nvim process is not closed, show Save All dialog. 62 | win.on('close', (e: Electron.Event) => { 63 | if (isConnected) { 64 | e.preventDefault(); 65 | showCloseDialog({ win, nvim }); 66 | } 67 | }); 68 | 69 | // After window is closed, continue quit app if this is a part of quit app routine 70 | win.on('closed', () => { 71 | if (shouldQuit) { 72 | app.quit(); 73 | } 74 | }); 75 | }; 76 | 77 | export default initQuit; 78 | -------------------------------------------------------------------------------- /packages/electron/src/main/autoUpdate.ts: -------------------------------------------------------------------------------- 1 | import { dialog, BrowserWindow } from 'electron'; 2 | import { autoUpdater } from 'electron-updater'; 3 | import html2plaintext from 'html2plaintext'; 4 | 5 | import { getSettings, onChangeSettings, SettingsCallback } from 'src/main/nvim/settings'; 6 | 7 | import store from 'src/main/lib/store'; 8 | 9 | let interval = 0; 10 | let updaterIntervalId: NodeJS.Timeout; 11 | 12 | const LAST_CHECKED = 'autoUpdate.lastCheckedForUpdate'; 13 | 14 | const MINUTE = 60 * 1000; 15 | 16 | const needToCheck = () => { 17 | if (interval === 0) { 18 | return false; 19 | } 20 | const lastChecked = store.get(LAST_CHECKED); 21 | if (!lastChecked) { 22 | return true; 23 | } 24 | return Date.now() - lastChecked > interval * MINUTE; 25 | }; 26 | 27 | const updater = () => { 28 | if (needToCheck()) { 29 | store.set(LAST_CHECKED, Date.now()); 30 | autoUpdater.checkForUpdates(); 31 | } 32 | }; 33 | 34 | const startUpdater = () => { 35 | if (!updaterIntervalId) { 36 | updaterIntervalId = setInterval(updater, MINUTE); 37 | } 38 | }; 39 | 40 | const updateInterval = (newInterval: string) => { 41 | if (interval !== parseInt(newInterval, 10)) { 42 | interval = parseInt(newInterval, 10); 43 | startUpdater(); 44 | } 45 | }; 46 | 47 | const handleChangeSettings: SettingsCallback = (_newSettings, allSettings) => { 48 | const { autoupdateinterval } = allSettings; 49 | if (autoupdateinterval !== undefined) { 50 | updateInterval(autoupdateinterval); 51 | } 52 | }; 53 | 54 | const handleUpdateAvailable = ({ 55 | version, 56 | releaseNotes, 57 | }: { 58 | version: string; 59 | releaseNotes: string; 60 | }) => { 61 | const response = dialog.showMessageBoxSync({ 62 | type: 'question', 63 | buttons: ['Update', 'Ignore'], 64 | defaultId: 0, 65 | message: `Version ${version} is available, do you want to install it now?`, 66 | detail: html2plaintext(releaseNotes), 67 | title: 'Update available', 68 | }); 69 | if (response === 0) { 70 | autoUpdater.downloadUpdate(); 71 | } 72 | }; 73 | 74 | const handleUpdateDownloaded = () => { 75 | dialog.showMessageBox({ 76 | type: 'question', 77 | buttons: ['OK'], 78 | defaultId: 0, 79 | message: `Update Downloaded`, 80 | detail: 'Please restart app to install update.', 81 | title: 'Update Downloaded', 82 | }); 83 | }; 84 | 85 | const initAutoUpdate = ({ win }: { win: BrowserWindow }): void => { 86 | updateInterval(getSettings().autoupdateinterval); 87 | onChangeSettings(win, handleChangeSettings); 88 | 89 | autoUpdater.autoDownload = false; 90 | 91 | autoUpdater.on('update-available', handleUpdateAvailable); 92 | autoUpdater.on('update-downloaded', handleUpdateDownloaded); 93 | }; 94 | 95 | export default initAutoUpdate; 96 | -------------------------------------------------------------------------------- /packages/nvim/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | type IsDevFunction = { 4 | (dev: T, notDev: F): T | F; 5 | (): boolean; 6 | }; 7 | 8 | export const isDev: IsDevFunction = (dev = true, notDev = false) => 9 | process.env.NODE_ENV === 'development' ? dev : notDev; 10 | 11 | export const nvimCommand = (env: NodeJS.ProcessEnv = {}): string => 12 | env.VV_NVIM_COMMAND || process.env.VV_NVIM_COMMAND || 'nvim'; 13 | 14 | /** 15 | * Cached patched `process.env` used in `shellEnv` function. 16 | */ 17 | let env: NodeJS.ProcessEnv | undefined; 18 | 19 | /** 20 | * Find env variables if the app is started from Finder. We need a correct PATH variable to 21 | * start nvim. 22 | */ 23 | export const shellEnv = (proc = process): NodeJS.ProcessEnv => { 24 | if (!env) { 25 | env = proc.env; 26 | // If we start app from terminal, it will have SHLVL variable. Then we already have correct 27 | // env variables and can skip this. 28 | if (!env.SHLVL) { 29 | try { 30 | // Try to get user's default shell and get env from it. 31 | const envString = execSync(`${env.SHELL || '/bin/bash'} -ilc env`, { encoding: 'utf-8' }); 32 | env = envString 33 | .split('\n') 34 | .filter(Boolean) 35 | .reduce((result, line) => { 36 | const [key, ...vals] = line.split('='); 37 | return { 38 | ...result, 39 | [key]: vals.join('='), 40 | }; 41 | }, {}); 42 | } catch (e) { 43 | // Most likely nvim is here: 44 | // * `/usr/local/bin` Homebrew default bin path 45 | // * `/opt/homebrew/bin` Homebrew bin path for Apple Silicon (https://docs.brew.sh/Installation) 46 | env.PATH = `/usr/local/bin:/opt/homebrew/bin:${env.PATH}`; 47 | } 48 | } 49 | } 50 | return env; 51 | }; 52 | 53 | /** 54 | * Cached Neovim version used in `nvimVersion` function. 55 | */ 56 | let version: string | undefined | null; 57 | 58 | /** 59 | * Get Neovim version string. 60 | */ 61 | export const nvimVersion = (): string | undefined | null => { 62 | if (version !== undefined) return version; 63 | 64 | const shEnv = shellEnv(); 65 | try { 66 | const execResult = execSync(`${nvimCommand(shEnv)} --version`, { 67 | encoding: 'utf-8', 68 | env: shEnv, 69 | }); 70 | if (execResult) { 71 | const match = execResult.match(/NVIM v(\d+)\.(\d+).(\d+)(.*)/); 72 | if (match) { 73 | version = `${match[1]}.${match[2]}.${match[3]}${match[4]}`; 74 | } 75 | } 76 | } catch (e) { 77 | version = null; 78 | } 79 | return version; 80 | }; 81 | 82 | /** @deprecated helper function for tests */ 83 | export const resetCache = (): void => { 84 | version = undefined; 85 | env = undefined; 86 | }; 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vv", 3 | "description": "Neovim GUI Client", 4 | "author": "Igor Gladkoborodov ", 5 | "keywords": [ 6 | "vim", 7 | "neovim", 8 | "client", 9 | "gui", 10 | "electron" 11 | ], 12 | "license": "MIT", 13 | "main": "./build/main.js", 14 | "sideEffects": false, 15 | "private": true, 16 | "workspaces": [ 17 | "packages/*" 18 | ], 19 | "scripts": { 20 | "bootstrap": "yarn build:nvim; yarn build:browser-renderer; yarn build:server", 21 | "build:nvim": "yarn workspace @vvim/nvim build", 22 | "build:browser-renderer": "yarn workspace @vvim/browser-renderer build", 23 | "build:electron": "yarn bootstrap; yarn workspace @vvim/electron build", 24 | "build:server": "yarn workspace @vvim/server build", 25 | "dev:nvim": "yarn workspace @vvim/nvim dev", 26 | "dev:browser-renderer": "yarn workspace @vvim/browser-renderer dev", 27 | "dev:electron": "yarn workspace @vvim/electron dev", 28 | "dev:server": "yarn workspace @vvim/server dev", 29 | "dev": "yarn bootstrap; npm-run-all --parallel dev:*", 30 | "start:electron": "yarn workspace @vvim/electron start", 31 | "start:server": "yarn workspace @vvim/server start", 32 | "lint": "eslint . --ext .js,.ts", 33 | "test": "jest", 34 | "typecheck": "tsc -p packages/browser-renderer; tsc -p packages/electron; tsc -p packages/server", 35 | "prepare": "husky install", 36 | "codegen": "babel-node -x \".ts\" scripts/codegen.ts" 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.24.0", 40 | "@babel/node": "^7.23.9", 41 | "@babel/plugin-proposal-class-properties": "^7.13.0", 42 | "@babel/plugin-proposal-optional-chaining": "^7.13.8", 43 | "@babel/plugin-transform-runtime": "^7.24.0", 44 | "@babel/preset-env": "^7.24.0", 45 | "@babel/preset-typescript": "^7.23.3", 46 | "@types/jest": "^30.0.0", 47 | "@typescript-eslint/eslint-plugin": "^4.15.2", 48 | "@typescript-eslint/parser": "^4.15.2", 49 | "babel-jest": "^30.1.1", 50 | "babel-loader": "^8.2.5", 51 | "babel-plugin-module-resolver": "^4.1.0", 52 | "codecov": "^3.8.1", 53 | "eslint": "^7.21.0", 54 | "eslint-config-airbnb-base": "^14.2.1", 55 | "eslint-config-prettier": "^8.1.0", 56 | "eslint-plugin-import": "^2.22.1", 57 | "eslint-plugin-jest": "^24.1.5", 58 | "eslint-plugin-prettier": "^3.3.1", 59 | "husky": "^5.2.0", 60 | "jest": "^30.1.1", 61 | "jest-github-actions-reporter": "^1.0.3", 62 | "lint-staged": "^10.5.4", 63 | "npm-run-all": "^4.1.5", 64 | "prettier": "^2.2.1", 65 | "regenerator": "^0.14.7", 66 | "ts-jest": "^29.4.1", 67 | "typescript": "^4.2.2", 68 | "webpack": "^5.90.3", 69 | "webpack-cli": "^5.1.4", 70 | "webpack-merge": "^5.10.0" 71 | }, 72 | "lint-staged": { 73 | "*.{ts,js,css,json,md}": [ 74 | "prettier --write" 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/nvim/src/__tests__/ProcNvimTransport.test.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { PassThrough } from 'stream'; 3 | import { encode } from 'msgpack-lite'; 4 | 5 | import type { ChildProcessWithoutNullStreams } from 'child_process'; 6 | 7 | import ProcNvimTransport from 'src/ProcNvimTransport'; 8 | 9 | describe('ProcNvimTransport', () => { 10 | let proc: ChildProcessWithoutNullStreams; 11 | let transport: ProcNvimTransport; 12 | const onData = jest.fn(); 13 | 14 | const remoteTransport = Object.assign(new EventEmitter(), { 15 | send: jest.fn(), 16 | }); 17 | 18 | beforeEach(() => { 19 | proc = Object.assign(new EventEmitter(), { 20 | stdout: new PassThrough(), 21 | stdin: new PassThrough(), 22 | } as unknown) as ChildProcessWithoutNullStreams; 23 | proc.stdin.on('data', onData); 24 | 25 | transport = new ProcNvimTransport(proc, remoteTransport); 26 | }); 27 | 28 | test('transport receives `nvim:data` event with msgpack-encoded data from proc.stdout', () => { 29 | const readCallback = jest.fn(); 30 | transport.on('nvim:data', readCallback); 31 | proc.stdout.push(encode('hello')); 32 | expect(readCallback).toHaveBeenCalledWith('hello'); 33 | }); 34 | 35 | test('transport emits nvim:close when proc is closed', () => { 36 | const handleClose = jest.fn(); 37 | transport.on('nvim:close', handleClose); 38 | proc.emit('close'); 39 | expect(handleClose).toHaveBeenCalled(); 40 | }); 41 | 42 | test('send to `nvim:write` writes msgpack-encoded data to stdin', async () => { 43 | transport.send('nvim:write', 10, 'command', ['param1', 'param2']); 44 | expect(onData).toHaveBeenCalledWith(encode([0, 10, 'command', ['param1', 'param2']])); 45 | }); 46 | 47 | test("don't write to stdin if it is not writable", async () => { 48 | proc.stdin.end(); 49 | transport.send('nvim:write', 10, 'command', ['param1', 'param2']); 50 | expect(onData).not.toHaveBeenCalled(); 51 | }); 52 | 53 | describe('remoteTransport', () => { 54 | test('receives and relays to proc.stin `nvim-send` event from remoteTransport', () => { 55 | remoteTransport.emit('nvim:write', 1, 'command', ['params']); 56 | expect(onData).toHaveBeenCalledWith(encode([0, 1, 'command', ['params']])); 57 | }); 58 | 59 | test('send `nvim:close` event to remoteTransport on close', () => { 60 | proc.emit('close'); 61 | expect(remoteTransport.send).toHaveBeenCalledWith('nvim:close'); 62 | }); 63 | 64 | test('translate nvim proc stdout data to remoteTransport', () => { 65 | proc.stdout.push(encode('hello')); 66 | expect(remoteTransport.send).toHaveBeenCalledWith('nvim:data', 'hello'); 67 | }); 68 | 69 | test('has attachRemoteTransport method', () => { 70 | transport = new ProcNvimTransport(proc); 71 | transport.attachRemoteTransport(remoteTransport); 72 | proc.stdout.push(encode('hello')); 73 | expect(remoteTransport.send).toHaveBeenCalledWith('nvim:data', 'hello'); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /packages/electron/src/main/transport/__tests__/ipc.test.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, IpcMain } from 'electron'; 2 | import { EventEmitter } from 'events'; 3 | 4 | import IpcTransport from 'src/main/transport/ipc'; 5 | 6 | describe('main transport', () => { 7 | let ipcMain: IpcMain; 8 | 9 | const win = (Object.assign(new EventEmitter(), { 10 | id: 'winId', 11 | webContents: { 12 | send: jest.fn(), 13 | }, 14 | }) as unknown) as BrowserWindow; 15 | 16 | let transport: IpcTransport; 17 | 18 | beforeEach(() => { 19 | win.removeAllListeners(); 20 | ipcMain = new EventEmitter() as IpcMain; 21 | transport = new IpcTransport(win, ipcMain); 22 | }); 23 | 24 | describe('on', () => { 25 | const listener = jest.fn(); 26 | 27 | test('calls listener if sender.id matches window id', () => { 28 | transport.on('test-event', listener); 29 | ipcMain.emit('test-event', { type: 'test-event', sender: { id: 'winId' } }, 'arg1', 'arg2'); 30 | expect(listener).toHaveBeenCalledWith('arg1', 'arg2'); 31 | }); 32 | 33 | test('does not call listener if sender.id does not match window id', () => { 34 | transport.on('test-event', listener); 35 | ipcMain.emit( 36 | 'test-event', 37 | { type: 'test-event', sender: { id: 'otherWinId' } }, 38 | 'arg1', 39 | 'arg2', 40 | ); 41 | expect(listener).not.toHaveBeenCalled(); 42 | }); 43 | 44 | test('listener with not args', () => { 45 | transport.on('test-event', listener); 46 | ipcMain.emit('test-event', { type: 'test-event', sender: { id: 'winId' } }); 47 | expect(listener).toHaveBeenCalledWith(); 48 | }); 49 | 50 | test('removes event listener on win `closed` event', () => { 51 | transport.on('test-event', listener); 52 | jest.spyOn(ipcMain, 'removeListener'); 53 | win.emit('closed'); 54 | expect(ipcMain.removeListener).toHaveBeenCalledWith('test-event', expect.any(Function)); 55 | }); 56 | }); 57 | 58 | test('unsubscribes from ipc event if there are not subscriptions left', () => { 59 | const listener = jest.fn(); 60 | const addListenerSpy = jest.spyOn(ipcMain, 'on'); 61 | const removeListenerSpy = jest.spyOn(ipcMain, 'removeListener'); 62 | transport.on('test-event', listener); 63 | transport.off('test-event', listener); 64 | 65 | expect(removeListenerSpy).toHaveBeenCalledWith('test-event', addListenerSpy.mock.calls[0][1]); 66 | }); 67 | 68 | describe('send', () => { 69 | test('pass args to win.webContents', () => { 70 | transport.send('test-event', 'arg1', 'arg2'); 71 | expect(win.webContents.send).toHaveBeenCalledWith('test-event', 'arg1', 'arg2'); 72 | }); 73 | 74 | test('with no args', () => { 75 | transport.send('test-event'); 76 | expect(win.webContents.send).toHaveBeenCalledWith('test-event'); 77 | }); 78 | 79 | test('does not send anything if window is closed', () => { 80 | win.emit('closed'); 81 | transport.send('test-event'); 82 | expect(win.webContents.send).not.toHaveBeenCalled(); 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /packages/electron/src/main/lib/args.ts: -------------------------------------------------------------------------------- 1 | // TODO: Use commander or yargs 2 | 3 | import isDev from 'src/lib/isDev'; 4 | 5 | const ARGS_WITH_PARAM = [ 6 | '--cmd', 7 | '-c', 8 | '-i', 9 | '-r', 10 | '-s', 11 | '-S', 12 | '-u', 13 | '--listen', 14 | '--startuptime', 15 | '--open-in-project', 16 | ]; 17 | 18 | /** 19 | * Args specific to VV. 20 | */ 21 | const VV_ARGS = ['--debug', '--inspect', '--open-in-project']; 22 | 23 | /** 24 | * Chromium args added by electron. 25 | * TODO: find more reliable way to filter them. 26 | */ 27 | const CHROMIUM_ARGS = [ 28 | '--allow-file-access-from-files', 29 | '--enable-avfoundation', 30 | '--force_high_performance_gpu', 31 | ]; 32 | 33 | /** 34 | * Parse CLI args and return the list of files and arguments. 35 | */ 36 | export const parseArgs = ( 37 | originalArgs: string[] = [], 38 | ): { 39 | args: string[]; 40 | files: string[]; 41 | } => { 42 | const args = [...originalArgs]; 43 | 44 | const filesSeparator = args.indexOf('--'); 45 | if (filesSeparator !== -1) { 46 | return { 47 | args: args.slice(0, filesSeparator), 48 | files: args.slice(filesSeparator + 1), 49 | }; 50 | } 51 | 52 | const files: string[] = []; 53 | for (let i = args.length - 1; i >= 0; i -= 1) { 54 | if (['-', '+'].includes(args[i][0]) || (args[i - 1] && ARGS_WITH_PARAM.includes(args[i - 1]))) { 55 | break; 56 | } 57 | files.unshift(args.pop() as string); 58 | } 59 | return { args, files }; 60 | }; 61 | 62 | /** 63 | * Join previously parsed args. 64 | */ 65 | export const joinArgs = ({ args, files }: { args: string[]; files: string[] }): string[] => { 66 | if (args.length === 0) { 67 | return files; 68 | } 69 | if (files.length === 0) { 70 | return args; 71 | } 72 | return [...args, '--', ...files]; 73 | }; 74 | 75 | /** 76 | * Argument value. 77 | * Returns true for argument that does not require argument if it is present. 78 | * Returns argument param for argumenst with params (for example --cmd). 79 | * Undefined if param is not present. 80 | */ 81 | export const argValue = (originalArgs: string[], argName: string): string | true | undefined => { 82 | const { args } = parseArgs(originalArgs); 83 | const index = args.indexOf(argName); 84 | if (index === -1) { 85 | return undefined; 86 | } 87 | if (ARGS_WITH_PARAM.includes(argName)) { 88 | return args[index + 1]; 89 | } 90 | return true; 91 | }; 92 | 93 | /** 94 | * Remove VV specific arguments not supported by nvim 95 | */ 96 | export const filterArgs = (args: string[]): string[] => 97 | args.reduce((result, a, i) => { 98 | if (VV_ARGS.includes(a) || CHROMIUM_ARGS.includes(a)) { 99 | return result; 100 | } 101 | if (args[i - 1] && VV_ARGS.includes(args[i - 1]) && ARGS_WITH_PARAM.includes(args[i - 1])) { 102 | return result; 103 | } 104 | return [...result, a]; 105 | }, []); 106 | 107 | /** 108 | * Get CLI arguments 109 | */ 110 | export const cliArgs = (args?: string[]): string[] | undefined => 111 | (args || process.argv).slice(isDev(2, 1)); 112 | -------------------------------------------------------------------------------- /packages/server/src/server/nvim/settings.ts: -------------------------------------------------------------------------------- 1 | import debounce from 'lodash/debounce'; 2 | 3 | import type { Nvim, Transport } from '@vvim/nvim'; 4 | 5 | type BooleanSetting = 0 | 1; 6 | 7 | export type Settings = { 8 | fullscreen: BooleanSetting; 9 | simplefullscreen: BooleanSetting; 10 | bold: BooleanSetting; 11 | italic: BooleanSetting; 12 | underline: BooleanSetting; 13 | undercurl: BooleanSetting; 14 | strikethrough: BooleanSetting; 15 | fontfamily: string; 16 | fontsize: string; // TODO: number 17 | lineheight: string; // TODO: number 18 | letterspacing: string; // TODO: number 19 | reloadchanged: BooleanSetting; 20 | quitoncloselastwindow: BooleanSetting; 21 | autoupdateinterval: string; // TODO: number 22 | openInProject: BooleanSetting; 23 | }; 24 | 25 | export type SettingsCallback = (newSettings: Partial, allSettings: Settings) => void; 26 | 27 | export const getDefaultSettings = (): Settings => ({ 28 | fullscreen: 0, 29 | simplefullscreen: 1, 30 | bold: 1, 31 | italic: 1, 32 | underline: 1, 33 | undercurl: 1, 34 | strikethrough: 1, 35 | fontfamily: 'monospace', 36 | fontsize: '12', 37 | lineheight: '1.25', 38 | letterspacing: '0', 39 | reloadchanged: 0, 40 | quitoncloselastwindow: 0, 41 | autoupdateinterval: '1440', // One day, 60*24 minutes 42 | openInProject: 0, 43 | }); 44 | 45 | let hasCustomConfig = false; 46 | 47 | const initSettings = ({ 48 | nvim, 49 | args, 50 | transport, 51 | }: { 52 | nvim: Nvim; 53 | args?: string[]; 54 | transport: Transport; 55 | }): void => { 56 | hasCustomConfig = args?.indexOf('-u') !== -1; 57 | let initialSettings: Settings | null = getDefaultSettings(); 58 | let settings = getDefaultSettings(); 59 | 60 | let newSettings: Partial = {}; 61 | 62 | const applyAllSettings = async () => { 63 | settings = { 64 | ...settings, 65 | ...newSettings, 66 | }; 67 | 68 | // If we have initial settings newSetting will be only those that different from initialSettings. We 69 | // aleady applied initialSettings when we created a window. 70 | if (initialSettings && !hasCustomConfig) { 71 | newSettings = Object.keys(settings).reduce>((result, key) => { 72 | // @ts-expect-error TODO FIXME 73 | if (initialSettings[key] !== settings[key]) { 74 | return { 75 | ...result, 76 | // @ts-expect-error TODO FIXME 77 | [key]: settings[key], 78 | }; 79 | } 80 | return result; 81 | }, {}); 82 | initialSettings = null; 83 | } 84 | 85 | transport.send('updateSettings', newSettings, settings); 86 | 87 | newSettings = {}; 88 | }; 89 | 90 | const debouncedApplyAllSettings = debounce(applyAllSettings, 10); 91 | 92 | const applySetting = ([option, props]: [K, Settings[K]]) => { 93 | if (props !== null) { 94 | newSettings[option] = props; 95 | debouncedApplyAllSettings(); 96 | } 97 | }; 98 | 99 | nvim.on('vv:set', applySetting); 100 | }; 101 | 102 | export default initSettings; 103 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/reloadChanged.ts: -------------------------------------------------------------------------------- 1 | import { dialog, BrowserWindow } from 'electron'; 2 | 3 | import type Nvim from '@vvim/nvim'; 4 | 5 | /** 6 | * Show dialog when opened files are changed externally. For example, when you switch git branches. It 7 | * will prompt you to keep your changes or reload the file. 8 | * Controlled by `:VVset reloadchanged` setting, off by default. 9 | * 10 | * Deprecated because this could be done via plugins and it was buggy anyway. 11 | * If you miss this feature, you can use https://github.com/igorgladkoborodov/load-all.vim plugin, that 12 | * does the same. 13 | * 14 | * TODO: remove on the next major version 15 | * 16 | * @deprecated 17 | */ 18 | const initReloadChanged = ({ nvim, win }: { nvim: Nvim; win: BrowserWindow }): void => { 19 | type Buffer = { 20 | bufnr: string; 21 | name: string; 22 | }; 23 | 24 | let changedBuffers: Record = {}; 25 | let enabled = false; 26 | let checking = false; 27 | 28 | const showChangedDialog = async () => { 29 | if (win.isFocused() && Object.keys(changedBuffers).length > 0) { 30 | const message = 31 | Object.keys(changedBuffers).length > 1 32 | ? `${ 33 | Object.keys(changedBuffers).length 34 | } opened files were changed outside. Do you want to reload them or keep your version?` 35 | : 'File was changed outside. Do you want to reload it or keep your version?'; 36 | 37 | const buttons = 38 | Object.keys(changedBuffers).length > 1 ? ['Reload All', 'Keep All'] : ['Reload', 'Keep']; 39 | 40 | const { response } = await dialog.showMessageBox(win, { 41 | message, 42 | detail: `${Object.keys(changedBuffers) 43 | .map((k) => changedBuffers[k].name) 44 | .join('\n')}\n`, 45 | cancelId: 1, 46 | defaultId: 0, 47 | buttons, 48 | }); 49 | if (response === 0) { 50 | nvim.callFunction( 51 | 'VVrefresh', 52 | Object.keys(changedBuffers).map((k) => changedBuffers[k].bufnr), 53 | ); 54 | changedBuffers = {}; 55 | } 56 | } 57 | }; 58 | 59 | const checktime = async () => { 60 | if (!checking) { 61 | checking = true; 62 | await nvim.command('checktime'); 63 | checking = false; 64 | showChangedDialog(); 65 | } 66 | }; 67 | 68 | const enable = (newEnabled = true) => { 69 | if (enabled !== !!newEnabled) { 70 | enabled = !!newEnabled; 71 | nvim.callFunction('VVenableReloadChanged', [enabled ? 1 : 0]); 72 | } 73 | }; 74 | 75 | nvim.on('vv:file_changed', ([buffer]: [Buffer]) => { 76 | if (enabled) { 77 | if (!changedBuffers[buffer.bufnr]) { 78 | changedBuffers[buffer.bufnr] = buffer; 79 | } 80 | checktime(); 81 | } 82 | }); 83 | 84 | nvim.on('vv:set', ([option, isEnabled]: [string, boolean]) => { 85 | if (option === 'reloadchanged') { 86 | enable(isEnabled); 87 | } 88 | }); 89 | 90 | win.on('focus', () => { 91 | if (enabled) { 92 | // The page will be blank on focus without timeout. 93 | setTimeout(() => checktime(), 10); 94 | } 95 | }); 96 | }; 97 | 98 | export default initReloadChanged; 99 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/__tests__/screen.test.ts: -------------------------------------------------------------------------------- 1 | /** @jest-environment node */ 2 | 3 | import puppeteer from 'puppeteer'; 4 | import { PORT } from 'config/jest/testServer'; 5 | 6 | import type { Browser, Page } from 'puppeteer'; 7 | 8 | describe('Screen', () => { 9 | jest.setTimeout(30000); 10 | 11 | let browser: Browser; 12 | let page: Page; 13 | 14 | beforeAll(async () => { 15 | browser = await puppeteer.launch({ 16 | headless: true, 17 | slowMo: 10, 18 | args: ['--no-sandbox', '--disable-setuid-sandbox'], 19 | }); 20 | }); 21 | 22 | afterAll(async () => { 23 | // await browser.close(); 24 | }); 25 | 26 | beforeEach(async () => { 27 | page = await browser.newPage(); 28 | 29 | await page.setViewport({ 30 | width: 300, 31 | height: 200, 32 | deviceScaleFactor: 2, 33 | }); 34 | 35 | await page.goto(`http://localhost:${PORT}`); 36 | await page.waitForSelector('input'); 37 | await page.keyboard.type(':VVset fontfamily=Courier\\ New'); 38 | await page.keyboard.press('Enter'); 39 | }); 40 | 41 | afterEach(async () => { 42 | await page.close(); 43 | }); 44 | 45 | it('match snapshot', async () => { 46 | await page.keyboard.type('iHello'); 47 | await page.keyboard.press('Escape'); 48 | 49 | const image = await page.screenshot(); 50 | expect(image).toMatchImageSnapshot(); 51 | }); 52 | 53 | it('redraw screen on default_colors_set', async () => { 54 | await page.keyboard.type(':colorscheme desert'); 55 | await page.keyboard.press('Enter'); 56 | 57 | const image = await page.screenshot(); 58 | expect(image).toMatchImageSnapshot(); 59 | }); 60 | 61 | test('show undercurl behind the text', async () => { 62 | await page.keyboard.type(':set filetype=javascript'); 63 | await page.keyboard.press('Enter'); 64 | await page.keyboard.type(':VVset lineheight=1'); 65 | await page.keyboard.press('Enter'); 66 | await page.keyboard.type(':syntax on'); 67 | await page.keyboard.press('Enter'); 68 | await page.keyboard.type(':hi Comment gui=undercurl guifg=white guisp=red'); 69 | await page.keyboard.press('Enter'); 70 | await page.keyboard.type('i// Hey!'); 71 | 72 | const image = await page.screenshot(); 73 | expect(image).toMatchImageSnapshot(); 74 | }); 75 | 76 | test('overlap chars', async () => { 77 | await page.keyboard.type(':VVset letterspacing=-8'); 78 | await page.keyboard.press('Enter'); 79 | await page.keyboard.type( 80 | 'i\n\n\nO O O O O O O O O O O OOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO', 81 | ); 82 | await page.keyboard.press('Escape'); 83 | 84 | await page.keyboard.type('hhhi '); 85 | await page.keyboard.press('Escape'); 86 | await page.keyboard.type('hhh'); 87 | const image = await page.screenshot(); 88 | expect(image).toMatchImageSnapshot(); 89 | 90 | await page.keyboard.type(':vs'); 91 | await page.keyboard.press('Enter'); 92 | await page.keyboard.type(':vs'); 93 | await page.keyboard.press('Enter'); 94 | await page.keyboard.type('i'); 95 | await page.keyboard.press('Escape'); 96 | 97 | await page.mouse.move(150, 100); 98 | await page.mouse.wheel({ deltaY: 100 }); 99 | 100 | const image1 = await page.screenshot(); 101 | expect(image1).toMatchImageSnapshot(); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/settings.ts: -------------------------------------------------------------------------------- 1 | import debounce from 'lodash/debounce'; 2 | 3 | import type { BrowserWindow } from 'electron'; 4 | import type { Nvim, Transport } from '@vvim/nvim'; 5 | 6 | import store, { Settings } from 'src/main/lib/store'; 7 | 8 | export type SettingsCallback = (newSettings: Partial, allSettings: Settings) => void; 9 | 10 | const getDefaultSettings = (): Settings => ({ 11 | fullscreen: 0, 12 | simplefullscreen: 1, 13 | bold: 1, 14 | italic: 1, 15 | underline: 1, 16 | undercurl: 1, 17 | strikethrough: 1, 18 | fontfamily: 'monospace', 19 | fontsize: '12', 20 | lineheight: '1.25', 21 | letterspacing: '0', 22 | reloadchanged: 0, 23 | quitoncloselastwindow: 0, 24 | autoupdateinterval: '1440', // One day, 60*24 minutes 25 | openInProject: 0, 26 | }); 27 | 28 | let hasCustomConfig = false; 29 | 30 | /** 31 | * Get saved settings if we have them, default settings otherwise. 32 | * If you run app with -u flag, return default settings. 33 | */ 34 | export const getSettings = (): Settings => { 35 | if (hasCustomConfig) { 36 | return getDefaultSettings(); 37 | } 38 | return { 39 | ...getDefaultSettings(), 40 | ...store.get('lastSettings'), 41 | }; 42 | }; 43 | 44 | const onChangeSettingsCallbacks: Record = {}; 45 | 46 | export const onChangeSettings = (win: BrowserWindow, callback: SettingsCallback): void => { 47 | if (!onChangeSettingsCallbacks[win.id]) { 48 | onChangeSettingsCallbacks[win.id] = []; 49 | } 50 | onChangeSettingsCallbacks[win.id].push(callback); 51 | }; 52 | 53 | const initSettings = ({ 54 | win, 55 | nvim, 56 | args, 57 | transport, 58 | }: { 59 | win: BrowserWindow; 60 | nvim: Nvim; 61 | args: string[]; 62 | transport: Transport; 63 | }): void => { 64 | hasCustomConfig = args.indexOf('-u') !== -1; 65 | let initialSettings: Settings | null = getSettings(); 66 | let settings = getDefaultSettings(); 67 | 68 | let newSettings: Partial = {}; 69 | 70 | const applyAllSettings = async () => { 71 | settings = { 72 | ...settings, 73 | ...newSettings, 74 | }; 75 | 76 | // If we have initial settings newSetting will be only those that different from initialSettings. We 77 | // aleady applied initialSettings when we created a window. 78 | // Also store default colors to settings to avoid blinks on init. 79 | if (initialSettings && !hasCustomConfig) { 80 | newSettings = Object.keys(settings).reduce>((result, key) => { 81 | // @ts-expect-error TODO FIXME 82 | if (initialSettings[key] !== settings[key]) { 83 | return { 84 | ...result, 85 | // @ts-expect-error TODO FIXME 86 | [key]: settings[key], 87 | }; 88 | } 89 | return result; 90 | }, {}); 91 | initialSettings = null; 92 | } 93 | store.set('lastSettings', settings); 94 | 95 | transport.send('updateSettings', newSettings, settings); 96 | if (onChangeSettingsCallbacks[win.id]) { 97 | onChangeSettingsCallbacks[win.id].forEach((c) => c(newSettings, settings)); 98 | } 99 | 100 | newSettings = {}; 101 | }; 102 | 103 | const debouncedApplyAllSettings = debounce(applyAllSettings, 10); 104 | 105 | const applySetting = ([option, props]: [K, Settings[K]]) => { 106 | if (props !== null) { 107 | newSettings[option] = props; 108 | debouncedApplyAllSettings(); 109 | } 110 | }; 111 | 112 | nvim.on('vv:set', applySetting); 113 | }; 114 | 115 | export default initSettings; 116 | -------------------------------------------------------------------------------- /packages/nvim/src/Nvim.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { nvimCommandNames } from 'src/__generated__/constants'; 4 | 5 | import type { Transport, MessageType, NvimInterface } from './types'; 6 | 7 | const NvimEventEmitter = (EventEmitter as unknown) as { new (): NvimInterface }; 8 | 9 | /** 10 | * Lightweight transport agnostic Neovim API client to be used in other @vvim packages. 11 | */ 12 | class Nvim extends NvimEventEmitter { 13 | private requestId = 0; 14 | 15 | private transport: Transport; 16 | 17 | private requestPromises: Record< 18 | string, 19 | { resolve: (result: any) => void; reject: (error: any) => void } 20 | > = {}; 21 | 22 | private isRenderer: boolean; 23 | 24 | constructor(transport: Transport, isRenderer = false) { 25 | super(); 26 | 27 | this.transport = transport; 28 | this.isRenderer = isRenderer; 29 | 30 | this.transport.on('nvim:data', (params: MessageType) => { 31 | if (params[0] === 0) { 32 | // eslint-disable-next-line no-console 33 | console.error('Unsupported request type', ...params); 34 | } else if (params[0] === 1) { 35 | this.handleResponse(params[1], params[2], params[3]); 36 | } else if (params[0] === 2) { 37 | this.emit(params[1], params[2]); 38 | } 39 | }); 40 | 41 | this.transport.on('nvim:close', () => { 42 | this.emit('close'); 43 | }); 44 | 45 | (Object.keys(nvimCommandNames) as Array).forEach( 46 | (commandName) => { 47 | (this as any)[commandName] = (...params: any[]) => 48 | this.request(nvimCommandNames[commandName], params); 49 | }, 50 | ); 51 | 52 | this.on('newListener', (eventName: string) => { 53 | if ( 54 | !this.listenerCount(eventName) && 55 | !['close', 'newListener', 'removeListener'].includes(eventName) && 56 | !eventName.startsWith('nvim:') 57 | ) { 58 | this.subscribe(eventName); 59 | } 60 | }); 61 | 62 | this.on('removeListener', (eventName: string) => { 63 | if ( 64 | !this.listenerCount(eventName) && 65 | !['close', 'newListener', 'removeListener'].includes(eventName) && 66 | !eventName.startsWith('nvim:') 67 | ) { 68 | this.unsubscribe(eventName); 69 | } 70 | }); 71 | } 72 | 73 | request(command: string, params: any[] = []): Promise { 74 | this.requestId += 1; 75 | // Workaround to avoid request ids conflict vetween main and renderer. Renderer ids are even, main ids are odd. 76 | // TODO: sync request id between all instances. 77 | const id = this.requestId * 2 + (this.isRenderer ? 0 : 1); 78 | this.transport.send('nvim:write', id, command, params); 79 | return new Promise((resolve, reject) => { 80 | this.requestPromises[id] = { 81 | resolve, 82 | reject, 83 | }; 84 | }); 85 | } 86 | 87 | private handleResponse(id: number, error: Error, result?: any): void { 88 | if (this.requestPromises[id]) { 89 | if (error) { 90 | this.requestPromises[id].reject(error); 91 | } else { 92 | this.requestPromises[id].resolve(result); 93 | } 94 | delete this.requestPromises[id]; 95 | } 96 | } 97 | 98 | /** 99 | * Fetch current mode from nvim, leaves only first letter to match groups of modes. 100 | * https://neovim.io/doc/user/eval.html#mode() 101 | */ 102 | getShortMode = async (): Promise => { 103 | const { mode } = await this.getMode(); 104 | return mode.replace('CTRL-', '')[0]; 105 | }; 106 | } 107 | 108 | export default Nvim; 109 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['jest'], 4 | extends: [ 5 | 'airbnb-base', 6 | // Temporary disable it until upgrade to the Prettier 3 7 | // 'plugin:prettier/recommended', 8 | 'plugin:jest/recommended', 9 | 'plugin:jest/style', 10 | 'plugin:import/typescript', 11 | ], 12 | env: { 13 | browser: true, 14 | 'jest/globals': true, 15 | }, 16 | ignorePatterns: [ 17 | '**/tmp/**', 18 | '**/build/**', 19 | '**/dist/**', 20 | '**/node_modules/**', 21 | '**/@types/**', 22 | '**/__generated__/**', 23 | ], 24 | settings: { 25 | 'import/resolver': { 26 | node: { 27 | extensions: ['.js', '.ts'], 28 | }, 29 | }, 30 | }, 31 | 32 | overrides: [ 33 | { 34 | files: ['*.ts'], 35 | plugins: ['jest', '@typescript-eslint'], 36 | extends: [ 37 | 'airbnb-base', 38 | // 'plugin:prettier/recommended', 39 | 'plugin:jest/recommended', 40 | 'plugin:jest/style', 41 | 'plugin:import/typescript', 42 | 'plugin:@typescript-eslint/recommended', 43 | ], 44 | rules: { 45 | 'prefer-destructuring': 'off', 46 | 'no-console': 'error', 47 | 'no-unused-vars': 'off', 48 | '@typescript-eslint/no-unused-vars': [ 49 | 'error', 50 | { 51 | args: 'all', 52 | argsIgnorePattern: '^_', 53 | }, 54 | ], 55 | '@typescript-eslint/ban-ts-comment': 'warn', 56 | '@typescript-eslint/no-explicit-any': 'warn', 57 | '@typescript-eslint/explicit-function-return-type': 'off', 58 | 'import/prefer-default-export': 'off', 59 | 'import/no-extraneous-dependencies': 'off', 60 | 'import/no-unresolved': 'off', // TypeScript handles this 61 | 'import/extensions': [ 62 | 'error', 63 | 'ignorePackages', 64 | { 65 | js: 'never', 66 | ts: 'never', 67 | }, 68 | ], 69 | 70 | // Styling. Remove after prettier upgrade 71 | 'object-curly-newline': 'off', 72 | 'function-paren-newline': 'off', 73 | 'implicit-arrow-linebreak': 'off', 74 | 'arrow-body-style': 'off', 75 | 'max-len': 'off', 76 | 'no-confusing-arrow': 'off', 77 | quotes: 'off', 78 | 'operator-linebreak': 'off', 79 | 'quote-props': 'off', 80 | indent: 'off', 81 | }, 82 | }, 83 | ], 84 | 85 | rules: { 86 | 'prefer-destructuring': 'off', 87 | 'no-console': 'error', 88 | 'no-unused-vars': [ 89 | 'error', 90 | { 91 | args: 'all', 92 | argsIgnorePattern: '^_', 93 | }, 94 | ], 95 | 'no-mixed-operators': [ 96 | 'error', 97 | { 98 | groups: [ 99 | ['&', '|', '^', '~', '<<', '>>', '>>>'], 100 | ['==', '!=', '===', '!==', '>', '>=', '<', '<='], 101 | ['&&', '||'], 102 | ['in', 'instanceof'], 103 | ], 104 | allowSamePrecedence: true, 105 | }, 106 | ], 107 | 'import/prefer-default-export': 'off', 108 | 'import/no-extraneous-dependencies': 'off', 109 | 'import/extensions': [ 110 | 'error', 111 | 'ignorePackages', 112 | { 113 | js: 'never', 114 | ts: 'never', 115 | }, 116 | ], 117 | 118 | // Styling. Remove after prettier upgrade 119 | 'object-curly-newline': 'off', 120 | 'function-paren-newline': 'off', 121 | 'implicit-arrow-linebreak': 'off', 122 | 'arrow-body-style': 'off', 123 | 'max-len': 'off', 124 | 'no-confusing-arrow': 'off', 125 | quotes: 'off', 126 | 'operator-linebreak': 'off', 127 | 'quote-props': 'off', 128 | indent: 'off', 129 | }, 130 | }; 131 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/input/mouse.ts: -------------------------------------------------------------------------------- 1 | import throttle from 'lodash/throttle'; 2 | 3 | import { modifierPrefix } from 'src/input/keyboard'; 4 | import { Screen } from 'src/screen'; 5 | import type Nvim from '@vvim/nvim'; 6 | 7 | const GRID = 0; 8 | 9 | const SCROLL_STEP_X = 6; 10 | const SCROLL_STEP_Y = 3; 11 | const MOUSE_BUTTON = { 12 | 0: 'left', 13 | 1: 'middle', 14 | 2: 'right', 15 | WHEEL: 'wheel', 16 | }; 17 | 18 | const ACTION = { 19 | UP: 'up', 20 | DOWN: 'down', 21 | LEFT: 'left', 22 | RIGHT: 'right', 23 | PRESS: 'press', 24 | DRAG: 'drag', 25 | RELEASE: 'release', 26 | } as const; 27 | 28 | type Action = typeof ACTION[keyof typeof ACTION]; 29 | 30 | // const initMouse = ({ screenCoords }: Screen, nvim: Nvim): void => { 31 | const initMouse = ({ screen, nvim }: { screen: Screen; nvim: Nvim }): void => { 32 | const { screenCoords } = screen; 33 | let scrollDeltaX = 0; 34 | let scrollDeltaY = 0; 35 | 36 | let mouseCoords: [number, number] = [0, 0]; 37 | let mouseButtonDown: boolean; 38 | 39 | const mouseCoordsChanged = (event: MouseEvent) => { 40 | const newCoords = screenCoords(event.clientX, event.clientY); 41 | if (newCoords[0] !== mouseCoords[0] || newCoords[1] !== mouseCoords[1]) { 42 | mouseCoords = newCoords; 43 | return true; 44 | } 45 | return false; 46 | }; 47 | 48 | const buttonName = (event: MouseEvent) => 49 | // @ts-expect-error TODO 50 | event.type === 'wheel' ? MOUSE_BUTTON.WHEEL : MOUSE_BUTTON[event.button]; 51 | 52 | const mouseInput = (event: MouseEvent, action: Action) => { 53 | mouseCoordsChanged(event); 54 | const [col, row] = screenCoords(event.clientX, event.clientY); 55 | const button = buttonName(event); 56 | const modifier = modifierPrefix(event); 57 | nvim.inputMouse(button, action, modifier, GRID, row, col); 58 | }; 59 | 60 | const calculateScroll = (event: MouseEvent) => { 61 | let [scrollX, scrollY] = screenCoords(Math.abs(scrollDeltaX), Math.abs(scrollDeltaY)); 62 | scrollX = Math.floor(scrollX / SCROLL_STEP_X); 63 | scrollY = Math.floor(scrollY / SCROLL_STEP_Y); 64 | 65 | if (scrollY === 0 && scrollX === 0) return; 66 | 67 | if (scrollY !== 0) { 68 | mouseInput(event, scrollDeltaY > 0 ? ACTION.DOWN : ACTION.UP); 69 | scrollDeltaY = 0; 70 | } 71 | 72 | if (scrollX !== 0) { 73 | mouseInput(event, scrollDeltaX > 0 ? ACTION.RIGHT : ACTION.LEFT); 74 | scrollDeltaX = 0; 75 | } 76 | }; 77 | 78 | const handleMousewheel = (event: WheelEvent) => { 79 | const { deltaX, deltaY } = event; 80 | if (scrollDeltaY * deltaY < 0) scrollDeltaY = 0; 81 | scrollDeltaX += deltaX; 82 | scrollDeltaY += deltaY; 83 | calculateScroll(event); 84 | }; 85 | 86 | const handleMousedown = (event: MouseEvent) => { 87 | event.preventDefault(); 88 | event.stopPropagation(); 89 | mouseButtonDown = true; 90 | mouseInput(event, ACTION.PRESS); 91 | }; 92 | 93 | const handleMouseup = (event: MouseEvent) => { 94 | event.preventDefault(); 95 | event.stopPropagation(); 96 | mouseButtonDown = false; 97 | mouseInput(event, ACTION.RELEASE); 98 | }; 99 | 100 | const handleMousemove = (event: MouseEvent) => { 101 | if (mouseButtonDown) { 102 | event.preventDefault(); 103 | event.stopPropagation(); 104 | if (mouseCoordsChanged(event)) mouseInput(event, ACTION.DRAG); 105 | } 106 | }; 107 | nvim.command('set mouse=a'); // Enable mouse events 108 | 109 | document.addEventListener('mousedown', handleMousedown); 110 | document.addEventListener('mouseup', handleMouseup); 111 | document.addEventListener('mousemove', throttle(handleMousemove, 50)); 112 | document.addEventListener('wheel', handleMousewheel); 113 | }; 114 | 115 | export default initMouse; 116 | -------------------------------------------------------------------------------- /packages/electron/src/main/menu.ts: -------------------------------------------------------------------------------- 1 | import { Menu, MenuItemConstructorOptions } from 'electron'; 2 | 3 | // import { handleCloseWindow } from 'src/main/nvim/features/closeWindow'; 4 | 5 | import { copyMenuItem, pasteMenuItem, selectAllMenuItem } from 'src/main/nvim/features/copyPaste'; 6 | import { zoomInMenuItem, zoomOutMenuItem, actualSizeMenuItem } from 'src/main/nvim/features/zoom'; 7 | import { closeWindowMenuItem } from 'src/main/nvim/features/closeWindow'; 8 | import { toggleFullScreenMenuItem } from 'src/main/nvim/features/windowSize'; 9 | 10 | let menu: Menu; 11 | 12 | const createMenu = ({ 13 | createWindow, 14 | openFile, 15 | installCli, 16 | }: { 17 | createWindow: () => void; 18 | openFile: MenuItemConstructorOptions['click']; 19 | installCli: MenuItemConstructorOptions['click']; 20 | }): void => { 21 | const menuTemplate: MenuItemConstructorOptions[] = [ 22 | { 23 | label: 'VV', 24 | submenu: [ 25 | { role: 'about' }, 26 | { 27 | label: 'Command Line Launcher...', 28 | click: installCli, 29 | }, 30 | { type: 'separator' }, 31 | { role: 'services', submenu: [] }, 32 | { type: 'separator' }, 33 | { role: 'hide' }, 34 | { role: 'hideOthers' }, 35 | { role: 'unhide' }, 36 | { type: 'separator' }, 37 | { role: 'quit' }, 38 | ], 39 | }, 40 | { 41 | label: 'File', 42 | submenu: [ 43 | { 44 | label: 'New Window', 45 | accelerator: 'CmdOrCtrl+N', 46 | click: () => createWindow(), 47 | }, 48 | { 49 | label: 'Open...', 50 | accelerator: 'CmdOrCtrl+O', 51 | click: openFile, 52 | }, 53 | { 54 | role: 'recentDocuments', 55 | submenu: [ 56 | { 57 | role: 'clearRecentDocuments', 58 | }, 59 | ], 60 | }, 61 | { type: 'separator' }, 62 | { 63 | label: 'Close', 64 | accelerator: 'CmdOrCtrl+W', 65 | click: closeWindowMenuItem, 66 | }, 67 | ], 68 | }, 69 | { 70 | label: 'Edit', 71 | submenu: [ 72 | { 73 | label: 'Copy', 74 | accelerator: 'CmdOrCtrl+C', 75 | click: copyMenuItem, 76 | }, 77 | { 78 | label: 'Paste', 79 | accelerator: 'CmdOrCtrl+V', 80 | click: pasteMenuItem, 81 | }, 82 | { 83 | label: 'Select All', 84 | accelerator: 'CmdOrCtrl+A', 85 | click: selectAllMenuItem, 86 | }, 87 | ], 88 | }, 89 | { 90 | label: 'View', 91 | submenu: [ 92 | { 93 | label: 'Toggle Full Screen', 94 | accelerator: 'Cmd+Ctrl+F', 95 | click: toggleFullScreenMenuItem, 96 | }, 97 | { 98 | label: 'Actual Size', 99 | id: 'actualSize', 100 | accelerator: 'CmdOrCtrl+0', 101 | click: actualSizeMenuItem, 102 | enabled: false, 103 | }, 104 | { 105 | label: 'Zoom In', 106 | accelerator: 'CmdOrCtrl+=', 107 | click: zoomInMenuItem, 108 | }, 109 | { 110 | label: 'Zoom Out', 111 | accelerator: 'CmdOrCtrl+-', 112 | click: zoomOutMenuItem, 113 | }, 114 | { type: 'separator' }, 115 | { 116 | label: 'Developer', 117 | submenu: [{ role: 'toggleDevTools' }], 118 | }, 119 | ], 120 | }, 121 | { 122 | role: 'window', 123 | submenu: [{ role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, { role: 'front' }], 124 | }, 125 | ]; 126 | menu = Menu.buildFromTemplate(menuTemplate); 127 | Menu.setApplicationMenu(menu); 128 | }; 129 | 130 | export default createMenu; 131 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/transport/__tests__/ipc.test.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { ipcRenderer } from 'src/preloaded/electron'; 3 | import type { PreloadedIpcRenderer } from 'src/preloaded/electron'; 4 | 5 | import IpcRendererTransport from 'src/transport/ipc'; 6 | 7 | jest.mock('src/preloaded/electron', () => ({ 8 | ipcRenderer: { 9 | on: jest.fn(), 10 | send: jest.fn(), 11 | }, 12 | })); 13 | 14 | describe('main transport', () => { 15 | let transport: IpcRendererTransport; 16 | let ipcRendererMock: NodeJS.EventEmitter; 17 | const send = jest.fn(); 18 | 19 | beforeEach(() => { 20 | ipcRendererMock = Object.assign(new EventEmitter(), { 21 | send, 22 | }); 23 | transport = new IpcRendererTransport((ipcRendererMock as unknown) as PreloadedIpcRenderer); 24 | }); 25 | 26 | describe('on', () => { 27 | const listener = jest.fn(); 28 | 29 | test('calls listener', () => { 30 | transport.on('test-event', listener); 31 | ipcRendererMock.emit('test-event', 'arg1', 'arg2'); 32 | expect(listener).toHaveBeenCalledWith('arg1', 'arg2'); 33 | }); 34 | 35 | test('does not call listener twice listener', () => { 36 | const anotherListener = jest.fn(); 37 | transport.on('test-event', listener); 38 | transport.on('test-event', anotherListener); 39 | ipcRendererMock.emit('test-event', new Event('test-event'), 'arg1', 'arg2'); 40 | expect(listener).toHaveBeenCalledTimes(1); 41 | expect(anotherListener).toHaveBeenCalledTimes(1); 42 | }); 43 | 44 | test('listener with no args', () => { 45 | transport.on('test-event', listener); 46 | ipcRendererMock.emit('test-event'); 47 | expect(listener).toHaveBeenCalledWith(); 48 | }); 49 | 50 | test('use preloaded ipcRenderer if it is not passed', () => { 51 | transport = new IpcRendererTransport(); 52 | transport.on('test-event', listener); 53 | expect(ipcRenderer.on).toHaveBeenCalledWith('test-event', expect.any(Function)); 54 | }); 55 | }); 56 | 57 | describe('once', () => { 58 | const listener = jest.fn(); 59 | 60 | test('calls listener once', () => { 61 | transport.once('test-event', listener); 62 | ipcRendererMock.emit('test-event', new Event('test-event'), 'arg1', 'arg2'); 63 | ipcRendererMock.emit('test-event', new Event('test-event'), 'arg1', 'arg2'); 64 | expect(listener).toHaveBeenCalledTimes(1); 65 | }); 66 | }); 67 | 68 | describe('removeListener', () => { 69 | const listener = jest.fn(); 70 | 71 | test('does not call listener after off', () => { 72 | transport.on('test-event', listener); 73 | transport.removeListener('test-event', listener); 74 | ipcRendererMock.emit('test-event', new Event('test-event'), 'arg1', 'arg2'); 75 | expect(listener).not.toHaveBeenCalled(); 76 | }); 77 | 78 | test('other subscribed events work', () => { 79 | const anotherListener = jest.fn(); 80 | transport.on('test-event', listener); 81 | transport.on('test-event', anotherListener); 82 | transport.removeListener('test-event', anotherListener); 83 | ipcRendererMock.emit('test-event', new Event('test-event'), 'arg1', 'arg2'); 84 | expect(listener).toHaveBeenCalled(); 85 | }); 86 | 87 | test('unsubscribes from ipc event if there are not subscriptions left', () => { 88 | const addListenerSpy = jest.spyOn(ipcRendererMock, 'on'); 89 | const removeListenerSpy = jest.spyOn(ipcRendererMock, 'removeListener'); 90 | transport.on('test-event', listener); 91 | transport.off('test-event', listener); 92 | 93 | expect(removeListenerSpy).toHaveBeenCalledWith('test-event', addListenerSpy.mock.calls[0][1]); 94 | }); 95 | }); 96 | 97 | describe('send', () => { 98 | test('pass args to win.webContents', () => { 99 | transport.send('test-event', 'arg1', 'arg2'); 100 | expect(send).toHaveBeenCalledWith('test-event', 'arg1', 'arg2'); 101 | }); 102 | 103 | test('with no args', () => { 104 | transport.send('test-event'); 105 | expect(send).toHaveBeenCalledWith('test-event'); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /packages/server/bin/vvset.vim: -------------------------------------------------------------------------------- 1 | let g:vv_settings_synonims = { 2 | \ 'fu': 'fullscreen', 3 | \ 'sfu': 'simplefullscreen', 4 | \ 'width': 'windowwidth', 5 | \ 'height': 'windowheight', 6 | \ 'top': 'windowtop', 7 | \ 'left': 'windowleft', 8 | \ 'openinproject': 'openInProject' 9 | \} 10 | 11 | let g:vv_default_settings = { 12 | \ 'fullscreen': 0, 13 | \ 'simplefullscreen': 1, 14 | \ 'bold': 1, 15 | \ 'italic': 1, 16 | \ 'underline': 1, 17 | \ 'undercurl': 1, 18 | \ 'strikethrough': 1, 19 | \ 'fontfamily': 'monospace', 20 | \ 'fontsize': 12, 21 | \ 'lineheight': 1.25, 22 | \ 'letterspacing': 0, 23 | \ 'reloadchanged': 0, 24 | \ 'windowwidth': v:null, 25 | \ 'windowheight': v:null, 26 | \ 'windowleft': v:null, 27 | \ 'windowtop': v:null, 28 | \ 'quitoncloselastwindow': 0, 29 | \ 'autoupdateinterval': 1440, 30 | \ 'openInProject': 1 31 | \} 32 | 33 | let g:vv_settings = deepcopy(g:vv_default_settings) 34 | 35 | " Custom VVset command, mimic default set command (:help set) with 36 | " settings specified in g:vv_default_settings 37 | function! VVset(...) 38 | for arg in a:000 39 | call VVsetItem(arg) 40 | endfor 41 | endfunction 42 | 43 | function! VVsettingValue(name) 44 | let l:name = VVsettingName(a:name) 45 | if has_key(g:vv_settings, l:name) 46 | return g:vv_settings[l:name] 47 | else 48 | echoerr "Unknown option: ".a:name 49 | endif 50 | endfunction 51 | 52 | function! VVsettingName(name) 53 | if has_key(g:vv_settings_synonims, a:name) 54 | return g:vv_settings_synonims[a:name] 55 | else 56 | return a:name 57 | endif 58 | endfunction 59 | 60 | function! VVsetItem(name) 61 | if a:name == 'all' 62 | echo g:vv_settings 63 | return 64 | elseif a:name =~ '?' 65 | let l:name = VVsettingName(split(a:name, '?')[0]) 66 | echo VVsettingValue(l:name) 67 | return 68 | elseif a:name =~ '&' 69 | let l:name = VVsettingName(split(a:name, '&')[0]) 70 | if l:name == 'all' 71 | let g:vv_settings = deepcopy(g:vv_default_settings) 72 | call VVsettings() 73 | return 74 | elseif has_key(g:vv_default_settings, l:name) 75 | let l:value = g:vv_default_settings[l:name] 76 | else 77 | echoerr "Unknown option: ".l:name 78 | return 79 | endif 80 | elseif a:name =~ '+=' 81 | let l:split = split(a:name, '+=') 82 | let l:name = VVsettingName(l:split[0]) 83 | let l:value = VVsettingValue(l:name) + l:split[1] 84 | elseif a:name =~ '-=' 85 | let l:split = split(a:name, '-=') 86 | let l:name = VVsettingName(l:split[0]) 87 | let l:value = VVsettingValue(l:name) - l:split[1] 88 | elseif a:name =~ '\^=' 89 | let l:split = split(a:name, '\^=') 90 | let l:name = VVsettingName(l:split[0]) 91 | let l:value = VVsettingValue(l:name) * l:split[1] 92 | elseif a:name =~ '=' 93 | let l:split = split(a:name, '=') 94 | let l:name = l:split[0] 95 | let l:value = l:split[1] 96 | elseif a:name =~ ':' 97 | let l:split = split(a:name, ':') 98 | let l:name = l:split[0] 99 | let l:value = l:split[1] 100 | elseif a:name =~ '!' 101 | let l:name = VVsettingName(split(a:name, '!')[0]) 102 | if VVsettingValue(l:name) == 0 103 | let l:value = 1 104 | else 105 | let l:value = 0 106 | endif 107 | elseif a:name =~ '^inv' 108 | let l:name = VVsettingName(strpart(a:name, 3)) 109 | if VVsettingValue(l:name) == 0 110 | let l:value = 1 111 | else 112 | let l:value = 0 113 | endif 114 | elseif a:name =~ '^no' 115 | let l:name = strpart(a:name, 2) 116 | let l:value = 0 117 | else 118 | let l:name = a:name 119 | let l:value = 1 120 | endif 121 | 122 | let l:name = VVsettingName(l:name) 123 | 124 | if has_key(g:vv_settings, l:name) 125 | let g:vv_settings[l:name] = l:value 126 | call rpcnotify(get(g:, "vv_channel", 1), "vv:set", l:name, l:value) 127 | else 128 | echoerr "Unknown option: ".l:name 129 | endif 130 | endfunction 131 | 132 | function! VVsettings() 133 | for key in keys(g:vv_settings) 134 | call rpcnotify(get(g:, "vv_channel", 1), "vv:set", key, g:vv_settings[key]) 135 | endfor 136 | endfunction 137 | 138 | command! -nargs=* VVset :call VVset() 139 | command! -nargs=* VVse :call VVset() 140 | command! -nargs=0 VVsettings :call VVsettings() " Send all settings to client 141 | -------------------------------------------------------------------------------- /packages/electron/bin/vvset.vim: -------------------------------------------------------------------------------- 1 | let g:vv_settings_synonims = { 2 | \ 'fu': 'fullscreen', 3 | \ 'sfu': 'simplefullscreen', 4 | \ 'width': 'windowwidth', 5 | \ 'height': 'windowheight', 6 | \ 'top': 'windowtop', 7 | \ 'left': 'windowleft', 8 | \ 'openinproject': 'openInProject' 9 | \} 10 | 11 | let g:vv_default_settings = { 12 | \ 'fullscreen': 0, 13 | \ 'simplefullscreen': 1, 14 | \ 'bold': 1, 15 | \ 'italic': 1, 16 | \ 'underline': 1, 17 | \ 'undercurl': 1, 18 | \ 'strikethrough': 1, 19 | \ 'fontfamily': 'monospace', 20 | \ 'fontsize': 12, 21 | \ 'lineheight': 1.25, 22 | \ 'letterspacing': 0, 23 | \ 'reloadchanged': 0, 24 | \ 'windowwidth': v:null, 25 | \ 'windowheight': v:null, 26 | \ 'windowleft': v:null, 27 | \ 'windowtop': v:null, 28 | \ 'quitoncloselastwindow': 0, 29 | \ 'autoupdateinterval': 1440, 30 | \ 'openInProject': 1 31 | \} 32 | 33 | let g:vv_settings = deepcopy(g:vv_default_settings) 34 | 35 | " Custom VVset command, mimic default set command (:help set) with 36 | " settings specified in g:vv_default_settings 37 | function! VVset(...) 38 | for arg in a:000 39 | call VVsetItem(arg) 40 | endfor 41 | endfunction 42 | 43 | function! VVsettingValue(name) 44 | let l:name = VVsettingName(a:name) 45 | if has_key(g:vv_settings, l:name) 46 | return g:vv_settings[l:name] 47 | else 48 | echoerr "Unknown option: ".a:name 49 | endif 50 | endfunction 51 | 52 | function! VVsettingName(name) 53 | if has_key(g:vv_settings_synonims, a:name) 54 | return g:vv_settings_synonims[a:name] 55 | else 56 | return a:name 57 | endif 58 | endfunction 59 | 60 | function! VVsetItem(name) 61 | if a:name == 'all' 62 | echo g:vv_settings 63 | return 64 | elseif a:name =~ '?' 65 | let l:name = VVsettingName(split(a:name, '?')[0]) 66 | echo VVsettingValue(l:name) 67 | return 68 | elseif a:name =~ '&' 69 | let l:name = VVsettingName(split(a:name, '&')[0]) 70 | if l:name == 'all' 71 | let g:vv_settings = deepcopy(g:vv_default_settings) 72 | call VVsettings() 73 | return 74 | elseif has_key(g:vv_default_settings, l:name) 75 | let l:value = g:vv_default_settings[l:name] 76 | else 77 | echoerr "Unknown option: ".l:name 78 | return 79 | endif 80 | elseif a:name =~ '+=' 81 | let l:split = split(a:name, '+=') 82 | let l:name = VVsettingName(l:split[0]) 83 | let l:value = VVsettingValue(l:name) + l:split[1] 84 | elseif a:name =~ '-=' 85 | let l:split = split(a:name, '-=') 86 | let l:name = VVsettingName(l:split[0]) 87 | let l:value = VVsettingValue(l:name) - l:split[1] 88 | elseif a:name =~ '\^=' 89 | let l:split = split(a:name, '\^=') 90 | let l:name = VVsettingName(l:split[0]) 91 | let l:value = VVsettingValue(l:name) * l:split[1] 92 | elseif a:name =~ '=' 93 | let l:split = split(a:name, '=') 94 | let l:name = l:split[0] 95 | let l:value = l:split[1] 96 | elseif a:name =~ ':' 97 | let l:split = split(a:name, ':') 98 | let l:name = l:split[0] 99 | let l:value = l:split[1] 100 | elseif a:name =~ '!' 101 | let l:name = VVsettingName(split(a:name, '!')[0]) 102 | if VVsettingValue(l:name) == 0 103 | let l:value = 1 104 | else 105 | let l:value = 0 106 | endif 107 | elseif a:name =~ '^inv' 108 | let l:name = VVsettingName(strpart(a:name, 3)) 109 | if VVsettingValue(l:name) == 0 110 | let l:value = 1 111 | else 112 | let l:value = 0 113 | endif 114 | elseif a:name =~ '^no' 115 | let l:name = strpart(a:name, 2) 116 | let l:value = 0 117 | else 118 | let l:name = a:name 119 | let l:value = 1 120 | endif 121 | 122 | let l:name = VVsettingName(l:name) 123 | 124 | if has_key(g:vv_settings, l:name) 125 | let g:vv_settings[l:name] = l:value 126 | call rpcnotify(get(g:, "vv_channel", 1), "vv:set", l:name, l:value) 127 | else 128 | echoerr "Unknown option: ".l:name 129 | endif 130 | endfunction 131 | 132 | function! VVsettings() 133 | for key in keys(g:vv_settings) 134 | call rpcnotify(get(g:, "vv_channel", 1), "vv:set", key, g:vv_settings[key]) 135 | endfor 136 | endfunction 137 | 138 | command! -nargs=* VVset :call VVset() 139 | command! -nargs=* VVse :call VVset() 140 | command! -nargs=0 VVsettings :call VVsettings() " Send all settings to client 141 | -------------------------------------------------------------------------------- /packages/nvim/src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | import type { EventEmitter } from 'events'; 4 | import type TypedEventEmitter from 'strict-event-emitter-types'; 5 | 6 | // Only use relative imports here because https://github.com/microsoft/TypeScript/issues/32999#issuecomment-523558695 7 | // TODO: Bundle .d.ts or something 8 | import type { 9 | UiEvents as UiEventsOriginal, 10 | NvimCommands as NvimCommandsOriginal, 11 | } from './__generated__/types'; 12 | import { nvimCommandNames } from './__generated__/constants'; 13 | 14 | export type RequestMessage = [0, number, string, any[]]; 15 | export type ResponseMessage = [1, number, any, any]; 16 | export type NotificationMessage = [2, string, any[]]; 17 | 18 | export type MessageType = RequestMessage | ResponseMessage | NotificationMessage; 19 | export type ReadCallback = (message: MessageType) => void; 20 | export type OnCloseCallback = () => void; 21 | 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | export type Args = any[]; 24 | 25 | export type Listener = (...args: Args) => void; 26 | 27 | /** 28 | * Remote transport between server or main and renderer. 29 | * Use emitter events (`on`, `once` etc) for receiving message, and `send` to send message to other side. 30 | */ 31 | export type Transport = EventEmitter & { 32 | /** 33 | * Send message to remote 34 | */ 35 | send: (channel: string, ...args: Args) => void; 36 | }; 37 | 38 | // Manual refine of the auto-generated UiEvents 39 | // More info: https://neovim.io/doc/user/ui.html 40 | 41 | export type ModeInfo = { 42 | cursor_shape: 'block' | 'horizontal' | 'vertical'; 43 | cell_percentage: number; 44 | blinkwait: number; 45 | blinkon: number; 46 | blinkoff: number; 47 | attr_id: number; 48 | attr_id_lm: number; 49 | short_name: string; // TODO: union 50 | name: string; // TODO: union 51 | mouse_shape: number; 52 | }; 53 | 54 | // TODO: refine this type as a union of `[option, value]` with the correct value type for each option. 55 | export type OptionSet = [ 56 | option: 57 | | 'arabicshape' 58 | | 'ambiwidth' 59 | | 'emoji' 60 | | 'guifont' 61 | | 'guifontwide' 62 | | 'linespace' 63 | | 'mousefocus' 64 | | 'pumblend' 65 | | 'showtabline' 66 | | 'termguicolors' 67 | | 'rgb' 68 | | 'ext_cmdline' 69 | | 'ext_popupmenu' 70 | | 'ext_tabline' 71 | | 'ext_wildmenu' 72 | | 'ext_messages' 73 | | 'ext_linegrid' 74 | | 'ext_multigrid' 75 | | 'ext_hlstate' 76 | | 'ext_termcolors', 77 | value: boolean | string, 78 | ]; 79 | 80 | export type HighlightAttrs = { 81 | foreground?: number; 82 | background?: number; 83 | special?: number; 84 | reverse?: boolean; 85 | standout?: boolean; 86 | italic?: boolean; 87 | bold?: boolean; 88 | underline?: boolean; 89 | undercurl?: boolean; 90 | strikethrough?: boolean; 91 | blend?: number; 92 | }; 93 | 94 | export type Cell = [text: string, hl_id?: number, repeat?: number]; 95 | 96 | type UiEventsPatch = { 97 | mode_info_set: [enabled: boolean, cursor_styles: ModeInfo[]]; 98 | option_set: OptionSet; 99 | hl_attr_define: [id: number, rgb_attrs: HighlightAttrs, cterm_attrs: HighlightAttrs, info: []]; 100 | grid_line: [grid: number, row: number, col_start: number, cells: Cell[]]; 101 | }; 102 | 103 | export type UiEvents = Omit & UiEventsPatch; 104 | 105 | export type UiEventsHandlers = { 106 | [Key in keyof UiEvents]: (params: Array) => void; 107 | }; 108 | 109 | type UiEventsArgsByKey = { 110 | [Key in keyof UiEvents]: [Key, ...Array]; 111 | }; 112 | 113 | export type UiEventsArgs = Array; 114 | 115 | export interface NvimEvents { 116 | redraw: (args: UiEventsArgs) => void; 117 | 118 | close: () => void; 119 | 120 | [x: string]: (...args: any[]) => void; 121 | } 122 | 123 | type NvimCommandsPatch = { 124 | nvim_get_mode: () => { mode: string }; 125 | }; 126 | 127 | export type NvimCommands = Omit & NvimCommandsPatch; 128 | 129 | type NvimCommandsMethods = { 130 | [K in keyof typeof nvimCommandNames]: < 131 | Return = ReturnType 132 | >( 133 | ...args: Parameters 134 | ) => Promise; 135 | }; 136 | export type NvimInterface = TypedEventEmitter & NvimCommandsMethods; 137 | -------------------------------------------------------------------------------- /packages/electron/src/main/lib/__tests__/args.test.ts: -------------------------------------------------------------------------------- 1 | import { parseArgs, joinArgs, filterArgs, argValue } from 'src/main/lib/args'; 2 | 3 | describe('parseArgs', () => { 4 | test('return empty array if input is empty', () => { 5 | expect(parseArgs([])).toEqual({ args: [], files: [] }); 6 | expect(parseArgs()).toEqual({ args: [], files: [] }); 7 | }); 8 | 9 | test('returns everything if there ar no params', () => { 10 | expect(parseArgs(['file1', 'file2'])).toEqual({ 11 | args: [], 12 | files: ['file1', 'file2'], 13 | }); 14 | }); 15 | 16 | test('returns everything after --', () => { 17 | expect(parseArgs(['before1', 'before2', '--', 'after1', 'after2'])).toEqual({ 18 | args: ['before1', 'before2'], 19 | files: ['after1', 'after2'], 20 | }); 21 | }); 22 | 23 | test('skip params started with - or +', () => { 24 | ['-param1', '--param2', '+cmd1'].forEach((param) => { 25 | expect(parseArgs([param, 'file1', 'file2'])).toEqual({ 26 | args: [param], 27 | files: ['file1', 'file2'], 28 | }); 29 | }); 30 | }); 31 | 32 | test('skip params with argument', () => { 33 | ['--cmd', '-c', '-i', '-r', '-s', '-S', '-u', '--listen', '--startuptime'].forEach((param) => { 34 | expect(parseArgs([param, 'arg', 'file1', 'file2'])).toEqual({ 35 | args: [param, 'arg'], 36 | files: ['file1', 'file2'], 37 | }); 38 | }); 39 | }); 40 | 41 | test('does not mutate arguments', () => { 42 | const args = ['arg1', 'arg2']; 43 | parseArgs(args); 44 | expect(args).toEqual(['arg1', 'arg2']); 45 | }); 46 | }); 47 | 48 | describe('joinArgs', () => { 49 | test('joins args and files arrays and put -- between them', () => { 50 | expect(joinArgs({ args: ['arg1', 'arg2'], files: ['file1', 'file2'] })).toEqual([ 51 | 'arg1', 52 | 'arg2', 53 | '--', 54 | 'file1', 55 | 'file2', 56 | ]); 57 | }); 58 | 59 | test("don't add -- if args is empty", () => { 60 | expect(joinArgs({ args: [], files: ['file1', 'file2'] })).toEqual(['file1', 'file2']); 61 | }); 62 | 63 | test("don't add -- if files is empty", () => { 64 | expect(joinArgs({ args: ['arg1'], files: [] })).toEqual(['arg1']); 65 | }); 66 | }); 67 | 68 | describe('filterArgs', () => { 69 | test('returns all args if none of them are VV-specific', () => { 70 | expect(filterArgs(['arg1', 'arg2'])).toEqual(['arg1', 'arg2']); 71 | }); 72 | 73 | test('filters out --inspect', () => { 74 | expect(filterArgs(['arg1', '--inspect', 'arg2'])).toEqual(['arg1', 'arg2']); 75 | expect(filterArgs(['--inspect', 'arg1', 'arg2'])).toEqual(['arg1', 'arg2']); 76 | expect(filterArgs(['arg1', 'arg2', '--inspect'])).toEqual(['arg1', 'arg2']); 77 | expect(filterArgs(['--inspect'])).toEqual([]); 78 | }); 79 | 80 | test('filters out --open-in-project with value', () => { 81 | expect(filterArgs(['arg1', '--open-in-project', 'value', 'arg2'])).toEqual(['arg1', 'arg2']); 82 | expect(filterArgs(['--open-in-project', 'value', 'arg1', 'arg2'])).toEqual(['arg1', 'arg2']); 83 | expect(filterArgs(['arg1', 'arg2', '--open-in-project'])).toEqual(['arg1', 'arg2']); 84 | expect(filterArgs(['--open-in-project'])).toEqual([]); 85 | expect(filterArgs(['--open-in-project', 'value'])).toEqual([]); 86 | }); 87 | 88 | test('filters out chromium flags', () => { 89 | expect( 90 | filterArgs(['arg1', '--allow-file-access-from-files', '--enable-avfoundation', 'arg2']), 91 | ).toEqual(['arg1', 'arg2']); 92 | }); 93 | }); 94 | 95 | describe('argValue', () => { 96 | test('returns true if argument is present', () => { 97 | expect(argValue(['--arg1', '--arg2'], '--arg1')).toBe(true); 98 | expect(argValue(['--arg1', '--arg2', 'file1'], '--arg1')).toBe(true); 99 | expect(argValue(['--arg1', '--arg2', '--', 'file1'], '--arg1')).toBe(true); 100 | }); 101 | 102 | test('returns undefined if argument is not present', () => { 103 | expect(argValue(['--arg1', '--arg2'], '--arg3')).toBeUndefined(); 104 | expect(argValue(['--arg1', '--', '--arg2'], '--arg2')).toBeUndefined(); 105 | }); 106 | 107 | test('returns value for argument with param', () => { 108 | expect(argValue(['--arg1', '--cmd', 'cmdValue', '--arg2'], '--cmd')).toBe('cmdValue'); 109 | expect(argValue(['--cmd', 'cmdValue'], '--cmd')).toBe('cmdValue'); 110 | expect(argValue(['--cmd', 'cmdValue', 'file1'], '--cmd')).toBe('cmdValue'); 111 | expect(argValue(['--cmd', 'cmdValue', '--', '--cmd', 'invalid'], '--cmd')).toBe('cmdValue'); 112 | }); 113 | 114 | test('returns undefined invalid argument with param', () => { 115 | expect(argValue(['--arg1', '--cmd'], '--cmd')).toBeUndefined(); 116 | expect(argValue(['--', '--cmd', 'cmdValue'], '--cmd')).toBeUndefined(); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /packages/electron/src/main/nvim/features/windowSize.ts: -------------------------------------------------------------------------------- 1 | import { screen, MenuItemConstructorOptions, BrowserWindow } from 'electron'; 2 | import type { Transport } from '@vvim/nvim'; 3 | 4 | import { getSettings, onChangeSettings, SettingsCallback } from 'src/main/nvim/settings'; 5 | import { getNvimByWindow } from 'src/main/nvim/nvimByWindow'; 6 | 7 | export const toggleFullScreenMenuItem: MenuItemConstructorOptions['click'] = (_item, win) => { 8 | const nvim = getNvimByWindow(win); 9 | if (nvim) { 10 | nvim.command('VVset fullscreen!'); 11 | } 12 | }; 13 | 14 | const initWindowSize = ({ transport, win }: { transport: Transport; win: BrowserWindow }): void => { 15 | const initialBounds = win.getBounds(); 16 | let bounds = win.getBounds(); 17 | let simpleFullScreen = false; 18 | let fullScreen = false; 19 | let isInitial = false; 20 | 21 | const set = { 22 | windowwidth: (w?: string) => { 23 | if (w !== undefined) { 24 | let width = parseInt(w, 10); 25 | if (w.toString().indexOf('%') !== -1) { 26 | width = Math.round((screen.getPrimaryDisplay().workAreaSize.width * width) / 100); 27 | } 28 | bounds.width = width; 29 | } 30 | }, 31 | windowheight: (h?: string) => { 32 | if (h !== undefined) { 33 | let height = parseInt(h, 10); 34 | if (h.toString().indexOf('%') !== -1) { 35 | height = Math.round((screen.getPrimaryDisplay().workAreaSize.height * height) / 100); 36 | } 37 | bounds.height = height; 38 | } 39 | }, 40 | windowleft: (l?: string) => { 41 | if (l !== undefined) { 42 | let left = parseInt(l, 10); 43 | if (l.toString().indexOf('%') !== -1) { 44 | const displayWidth = screen.getPrimaryDisplay().workAreaSize.width; 45 | const winWidth = bounds.width; 46 | left = Math.round(((displayWidth - winWidth) * left) / 100); 47 | } 48 | bounds.x = left; 49 | } 50 | }, 51 | windowtop: (t?: string) => { 52 | if (t !== undefined) { 53 | let top = parseInt(t, 10); 54 | if (t.toString().indexOf('%') !== -1) { 55 | const displayHeight = screen.getPrimaryDisplay().workAreaSize.height; 56 | const winHeight = bounds.height; 57 | top = Math.round(((displayHeight - winHeight) * top) / 100); 58 | } 59 | bounds.y = top; 60 | } 61 | }, 62 | fullscreen: (value: string) => { 63 | fullScreen = !!parseInt(value, 10); 64 | if (fullScreen) bounds = win.getBounds(); 65 | if (simpleFullScreen) { 66 | win.setSimpleFullScreen(fullScreen); 67 | } else { 68 | win.setFullScreen(fullScreen); 69 | } 70 | win.webContents.focus(); 71 | }, 72 | simplefullscreen: (value: string) => { 73 | simpleFullScreen = !!parseInt(value, 10); 74 | if (simpleFullScreen && win.isFullScreen()) { 75 | win.setFullScreen(false); 76 | setTimeout(() => { 77 | win.setSimpleFullScreen(true); 78 | win.webContents.focus(); 79 | }, 1); 80 | } else if (!simpleFullScreen && win.isSimpleFullScreen()) { 81 | win.setSimpleFullScreen(false); 82 | setTimeout(() => { 83 | win.setFullScreen(true); 84 | win.webContents.focus(); 85 | }, 1); 86 | } 87 | win.fullScreenable = !simpleFullScreen; // eslint-disable-line no-param-reassign 88 | }, 89 | }; 90 | 91 | const updateWindowSize: SettingsCallback = (newSettings, allSettings) => { 92 | let settings = newSettings; 93 | if (!fullScreen) { 94 | bounds = win.getBounds(); 95 | } 96 | if (isInitial && allSettings.fullscreen === 0) { 97 | settings = allSettings; 98 | bounds = initialBounds; 99 | isInitial = false; 100 | } 101 | // Order is iportant. 102 | [ 103 | 'simplefullscreen', 104 | 'fullscreen', 105 | 'windowwidth', 106 | 'windowheight', 107 | 'windowleft', 108 | 'windowtop', 109 | // @ts-expect-error FIXME 110 | ].forEach((key) => settings[key] !== undefined && set[key](settings[key])); 111 | if (!fullScreen) { 112 | win.setBounds(bounds); 113 | } 114 | }; 115 | 116 | updateWindowSize(getSettings(), getSettings()); 117 | isInitial = true; 118 | 119 | onChangeSettings(win, updateWindowSize); 120 | 121 | transport.on('set-screen-width', (width: number) => { 122 | const height = win.getContentSize()[1]; 123 | win.setContentSize(width, height); 124 | }); 125 | 126 | transport.on('set-screen-height', (height: number) => { 127 | const [width, oldHeight] = win.getContentSize(); 128 | win.setContentSize(width, height); 129 | // The new height is more than screen height. 130 | if (win.getContentSize()[1] === oldHeight) { 131 | transport.send('force-resize'); 132 | } 133 | }); 134 | }; 135 | 136 | export default initWindowSize; 137 | -------------------------------------------------------------------------------- /scripts/codegen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | import { spawn } from 'child_process'; 4 | import { createDecodeStream, encode } from 'msgpack-lite'; 5 | import { writeFileSync } from 'fs'; 6 | import prettier from 'prettier'; 7 | import camelCase from 'lodash/camelCase'; 8 | 9 | const TYPES_FILE_NAME = 'packages/nvim/src/__generated__/types.ts'; 10 | const CONST_FILE_NAME = 'packages/nvim/src/__generated__/constants.ts'; 11 | 12 | const nvimProcess = spawn('nvim', ['--embed', '-u', 'NONE']); 13 | 14 | nvimProcess.stderr.pipe(process.stdout); 15 | 16 | const decodeStream = createDecodeStream(); 17 | const msgpackIn = nvimProcess.stdout.pipe(decodeStream); 18 | 19 | const replaceType = (originalType: string) => { 20 | const replacements = { 21 | Array: 'Array', 22 | String: 'string', 23 | Integer: 'number', 24 | Boolean: 'boolean', 25 | Float: 'number', 26 | Dictionary: 'Record', 27 | Object: 'any', 28 | Window: 'number', 29 | Buffer: 'number', 30 | Tabpage: 'number', 31 | LuaRef: 'any', 32 | 'ArrayOf(String)': 'string[]', 33 | 'ArrayOf(Integer)': 'number[]', 34 | 'ArrayOf(Integer, 2)': '[number, number]', 35 | 'ArrayOf(Dictionary)': 'Record[]', 36 | 'ArrayOf(Window)': 'number[]', 37 | 'ArrayOf(Buffer)': 'number[]', 38 | 'ArrayOf(Tabpage)': 'number[]', 39 | } as Record; 40 | return replacements[originalType] || originalType; 41 | }; 42 | 43 | const replaceName = (originalName: string) => { 44 | const replacements = { 45 | window: 'win', 46 | } as Record; 47 | 48 | return replacements[originalName] || originalName; 49 | }; 50 | 51 | msgpackIn.on('data', (data) => { 52 | const apiInfo = data[3][1]; 53 | writeFileSync('tmp/apiInfo.json', JSON.stringify(apiInfo, null, 2), { encoding: 'utf8' }); 54 | const { ui_events, functions } = apiInfo; 55 | 56 | let result: string[] = []; 57 | 58 | const version = [apiInfo.version.major, apiInfo.version.minor, apiInfo.version.patch].join('.'); 59 | 60 | result.push('/* eslint-disable camelcase */'); 61 | result.push('/**'); 62 | result.push(' * Types generated by `yarn generate-types`. Do not edit manually.'); 63 | result.push(' * '); 64 | result.push(` * Version: ${version}`); 65 | result.push(` * Api Level: ${apiInfo.version.api_level}`); 66 | result.push(` * Api Compatible: ${apiInfo.version.api_compatible}`); 67 | result.push(` * Api Prerelease: ${apiInfo.version.api_prerelease}`); 68 | result.push(' */'); 69 | result.push(''); 70 | 71 | result.push('/**'); 72 | result.push(' * UI events types emitted by `redraw` event. Do not edit manually.'); 73 | result.push(' * More info: https://neovim.io/doc/user/ui.html'); 74 | result.push(' */'); 75 | 76 | result.push('export type UiEvents = {'); 77 | ui_events.forEach(({ name, parameters }: { name: string; parameters: string[][] }) => { 78 | const parametersType = parameters.map(([type, typeName]) => { 79 | return `${typeName}: ${replaceType(type)}`; 80 | }); 81 | result.push(` ${name}: [${parametersType.join(', ')}];\n`); 82 | }); 83 | result.push('}\n'); 84 | 85 | result.push('/**'); 86 | result.push(' * Nvim commands.'); 87 | result.push(' * More info: https://neovim.io/doc/user/api.html'); 88 | result.push(' */'); 89 | 90 | result.push('export type NvimCommands = {'); 91 | functions 92 | .filter((f) => !f.deprecated_since) 93 | .forEach( 94 | ({ 95 | name, 96 | parameters, 97 | return_type, 98 | }: { 99 | name: string; 100 | parameters: string[][]; 101 | return_type: string; 102 | }) => { 103 | const parametersType = parameters.map(([type, typeName]) => { 104 | return `${replaceName(typeName)}: ${replaceType(type)}`; 105 | }); 106 | result.push(` ${name}: (${parametersType.join(', ')}) => ${replaceType(return_type)};\n`); 107 | }, 108 | ); 109 | result.push('}\n'); 110 | 111 | const prettifiedTypes = prettier.format(result.join('\n'), { parser: 'typescript' }); 112 | 113 | writeFileSync(TYPES_FILE_NAME, prettifiedTypes, { 114 | encoding: 'utf8', 115 | }); 116 | 117 | result = []; 118 | result.push('/* eslint-disable camelcase */'); 119 | result.push('/**'); 120 | result.push(' * Constants generated by `yarn generate-types`. Do not edit manually.'); 121 | result.push(' * '); 122 | result.push(` * Version: ${version}`); 123 | result.push(` * Api Level: ${apiInfo.version.api_level}`); 124 | result.push(` * Api Compatible: ${apiInfo.version.api_compatible}`); 125 | result.push(` * Api Prerelease: ${apiInfo.version.api_prerelease}`); 126 | result.push(' */'); 127 | result.push(''); 128 | 129 | result.push('export const nvimCommandNames = {'); 130 | functions 131 | .filter((f) => !f.deprecated_since) 132 | .forEach(({ name }: { name: string }) => { 133 | result.push(` ${camelCase(name.replace('nvim_', ''))}: '${name}',`); 134 | }); 135 | result.push('} as const;\n'); 136 | 137 | const prettifiedConst = prettier.format(result.join('\n'), { parser: 'typescript' }); 138 | 139 | writeFileSync(CONST_FILE_NAME, prettifiedConst, { 140 | encoding: 'utf8', 141 | }); 142 | }); 143 | 144 | nvimProcess.stdin.write(encode([0, 1, 'nvim_get_api_info', []])); 145 | 146 | setTimeout(() => { 147 | nvimProcess.stdin.write(encode([0, 1, 'nvim_command', ['q']])); 148 | }, 100); 149 | -------------------------------------------------------------------------------- /packages/nvim/src/__generated__/constants.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | /** 3 | * Constants generated by `yarn generate-types`. Do not edit manually. 4 | * 5 | * Version: 0.5.0 6 | * Api Level: 7 7 | * Api Compatible: 0 8 | * Api Prerelease: false 9 | */ 10 | 11 | export const nvimCommandNames = { 12 | bufLineCount: 'nvim_buf_line_count', 13 | bufAttach: 'nvim_buf_attach', 14 | bufDetach: 'nvim_buf_detach', 15 | bufGetLines: 'nvim_buf_get_lines', 16 | bufSetLines: 'nvim_buf_set_lines', 17 | bufSetText: 'nvim_buf_set_text', 18 | bufGetOffset: 'nvim_buf_get_offset', 19 | bufGetVar: 'nvim_buf_get_var', 20 | bufGetChangedtick: 'nvim_buf_get_changedtick', 21 | bufGetKeymap: 'nvim_buf_get_keymap', 22 | bufSetKeymap: 'nvim_buf_set_keymap', 23 | bufDelKeymap: 'nvim_buf_del_keymap', 24 | bufGetCommands: 'nvim_buf_get_commands', 25 | bufSetVar: 'nvim_buf_set_var', 26 | bufDelVar: 'nvim_buf_del_var', 27 | bufGetOption: 'nvim_buf_get_option', 28 | bufSetOption: 'nvim_buf_set_option', 29 | bufGetName: 'nvim_buf_get_name', 30 | bufSetName: 'nvim_buf_set_name', 31 | bufIsLoaded: 'nvim_buf_is_loaded', 32 | bufDelete: 'nvim_buf_delete', 33 | bufIsValid: 'nvim_buf_is_valid', 34 | bufGetMark: 'nvim_buf_get_mark', 35 | bufGetExtmarkById: 'nvim_buf_get_extmark_by_id', 36 | bufGetExtmarks: 'nvim_buf_get_extmarks', 37 | bufSetExtmark: 'nvim_buf_set_extmark', 38 | bufDelExtmark: 'nvim_buf_del_extmark', 39 | bufAddHighlight: 'nvim_buf_add_highlight', 40 | bufClearNamespace: 'nvim_buf_clear_namespace', 41 | bufSetVirtualText: 'nvim_buf_set_virtual_text', 42 | bufCall: 'nvim_buf_call', 43 | tabpageListWins: 'nvim_tabpage_list_wins', 44 | tabpageGetVar: 'nvim_tabpage_get_var', 45 | tabpageSetVar: 'nvim_tabpage_set_var', 46 | tabpageDelVar: 'nvim_tabpage_del_var', 47 | tabpageGetWin: 'nvim_tabpage_get_win', 48 | tabpageGetNumber: 'nvim_tabpage_get_number', 49 | tabpageIsValid: 'nvim_tabpage_is_valid', 50 | uiAttach: 'nvim_ui_attach', 51 | uiDetach: 'nvim_ui_detach', 52 | uiTryResize: 'nvim_ui_try_resize', 53 | uiSetOption: 'nvim_ui_set_option', 54 | uiTryResizeGrid: 'nvim_ui_try_resize_grid', 55 | uiPumSetHeight: 'nvim_ui_pum_set_height', 56 | uiPumSetBounds: 'nvim_ui_pum_set_bounds', 57 | exec: 'nvim_exec', 58 | command: 'nvim_command', 59 | getHlByName: 'nvim_get_hl_by_name', 60 | getHlById: 'nvim_get_hl_by_id', 61 | getHlIdByName: 'nvim_get_hl_id_by_name', 62 | setHl: 'nvim_set_hl', 63 | feedkeys: 'nvim_feedkeys', 64 | input: 'nvim_input', 65 | inputMouse: 'nvim_input_mouse', 66 | replaceTermcodes: 'nvim_replace_termcodes', 67 | eval: 'nvim_eval', 68 | execLua: 'nvim_exec_lua', 69 | notify: 'nvim_notify', 70 | callFunction: 'nvim_call_function', 71 | callDictFunction: 'nvim_call_dict_function', 72 | strwidth: 'nvim_strwidth', 73 | listRuntimePaths: 'nvim_list_runtime_paths', 74 | getRuntimeFile: 'nvim_get_runtime_file', 75 | setCurrentDir: 'nvim_set_current_dir', 76 | getCurrentLine: 'nvim_get_current_line', 77 | setCurrentLine: 'nvim_set_current_line', 78 | delCurrentLine: 'nvim_del_current_line', 79 | getVar: 'nvim_get_var', 80 | setVar: 'nvim_set_var', 81 | delVar: 'nvim_del_var', 82 | getVvar: 'nvim_get_vvar', 83 | setVvar: 'nvim_set_vvar', 84 | getOption: 'nvim_get_option', 85 | getAllOptionsInfo: 'nvim_get_all_options_info', 86 | getOptionInfo: 'nvim_get_option_info', 87 | setOption: 'nvim_set_option', 88 | echo: 'nvim_echo', 89 | outWrite: 'nvim_out_write', 90 | errWrite: 'nvim_err_write', 91 | errWriteln: 'nvim_err_writeln', 92 | listBufs: 'nvim_list_bufs', 93 | getCurrentBuf: 'nvim_get_current_buf', 94 | setCurrentBuf: 'nvim_set_current_buf', 95 | listWins: 'nvim_list_wins', 96 | getCurrentWin: 'nvim_get_current_win', 97 | setCurrentWin: 'nvim_set_current_win', 98 | createBuf: 'nvim_create_buf', 99 | openTerm: 'nvim_open_term', 100 | chanSend: 'nvim_chan_send', 101 | openWin: 'nvim_open_win', 102 | listTabpages: 'nvim_list_tabpages', 103 | getCurrentTabpage: 'nvim_get_current_tabpage', 104 | setCurrentTabpage: 'nvim_set_current_tabpage', 105 | createNamespace: 'nvim_create_namespace', 106 | getNamespaces: 'nvim_get_namespaces', 107 | paste: 'nvim_paste', 108 | put: 'nvim_put', 109 | subscribe: 'nvim_subscribe', 110 | unsubscribe: 'nvim_unsubscribe', 111 | getColorByName: 'nvim_get_color_by_name', 112 | getColorMap: 'nvim_get_color_map', 113 | getContext: 'nvim_get_context', 114 | loadContext: 'nvim_load_context', 115 | getMode: 'nvim_get_mode', 116 | getKeymap: 'nvim_get_keymap', 117 | setKeymap: 'nvim_set_keymap', 118 | delKeymap: 'nvim_del_keymap', 119 | getCommands: 'nvim_get_commands', 120 | getApiInfo: 'nvim_get_api_info', 121 | setClientInfo: 'nvim_set_client_info', 122 | getChanInfo: 'nvim_get_chan_info', 123 | listChans: 'nvim_list_chans', 124 | callAtomic: 'nvim_call_atomic', 125 | parseExpression: 'nvim_parse_expression', 126 | listUis: 'nvim_list_uis', 127 | getProcChildren: 'nvim_get_proc_children', 128 | getProc: 'nvim_get_proc', 129 | selectPopupmenuItem: 'nvim_select_popupmenu_item', 130 | setDecorationProvider: 'nvim_set_decoration_provider', 131 | winGetBuf: 'nvim_win_get_buf', 132 | winSetBuf: 'nvim_win_set_buf', 133 | winGetCursor: 'nvim_win_get_cursor', 134 | winSetCursor: 'nvim_win_set_cursor', 135 | winGetHeight: 'nvim_win_get_height', 136 | winSetHeight: 'nvim_win_set_height', 137 | winGetWidth: 'nvim_win_get_width', 138 | winSetWidth: 'nvim_win_set_width', 139 | winGetVar: 'nvim_win_get_var', 140 | winSetVar: 'nvim_win_set_var', 141 | winDelVar: 'nvim_win_del_var', 142 | winGetOption: 'nvim_win_get_option', 143 | winSetOption: 'nvim_win_set_option', 144 | winGetPosition: 'nvim_win_get_position', 145 | winGetTabpage: 'nvim_win_get_tabpage', 146 | winGetNumber: 'nvim_win_get_number', 147 | winIsValid: 'nvim_win_is_valid', 148 | winSetConfig: 'nvim_win_set_config', 149 | winGetConfig: 'nvim_win_get_config', 150 | winHide: 'nvim_win_hide', 151 | winClose: 'nvim_win_close', 152 | winCall: 'nvim_win_call', 153 | } as const; 154 | -------------------------------------------------------------------------------- /packages/nvim/src/__tests__/Nvim.test.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import Nvim from 'src/nvim'; 3 | 4 | import type { Transport } from 'src/types'; 5 | 6 | describe('Nvim', () => { 7 | const send = jest.fn(); 8 | 9 | const transportMock: Transport = Object.assign(new EventEmitter(), { 10 | send, 11 | }); 12 | 13 | let nvim: Nvim; 14 | 15 | beforeEach(() => { 16 | transportMock.removeAllListeners(); 17 | nvim = new Nvim(transportMock); 18 | }); 19 | 20 | describe('request', () => { 21 | test('call send with `nvim:write` on request', () => { 22 | nvim.request('nvim_command', ['param1', 'param2']); 23 | expect(send).toHaveBeenCalledWith('nvim:write', 3, 'nvim_command', ['param1', 'param2']); 24 | }); 25 | 26 | test('increment request id on second call and it is always odd', () => { 27 | nvim.request('nvim_command1'); 28 | expect(send).toHaveBeenCalledWith('nvim:write', 3, 'nvim_command1', []); 29 | nvim.request('nvim_command2'); 30 | expect(send).toHaveBeenCalledWith('nvim:write', 5, 'nvim_command2', []); 31 | }); 32 | 33 | test('in renderer mode request id is always even', () => { 34 | nvim = new Nvim(transportMock, true); 35 | nvim.request('nvim_command1'); 36 | expect(send).toHaveBeenCalledWith('nvim:write', 2, 'nvim_command1', []); 37 | nvim.request('nvim_command2'); 38 | expect(send).toHaveBeenCalledWith('nvim:write', 4, 'nvim_command2', []); 39 | }); 40 | 41 | test('receives result of request', async () => { 42 | const resultPromise = nvim.request('nvim_command', ['param1', 'param2']); 43 | transportMock.emit('nvim:data', [1, 3, null, 'result']); 44 | expect(await resultPromise).toEqual('result'); 45 | }); 46 | 47 | test('reject on error returned', async () => { 48 | const resultPromise = nvim.request('nvim_command', ['param1', 'param2']); 49 | transportMock.emit('nvim:data', [1, 3, 'error']); 50 | await expect(resultPromise).rejects.toEqual('error'); 51 | }); 52 | }); 53 | 54 | describe('notification', () => { 55 | test('send `nvim_subscribe` when you subscribe', () => { 56 | nvim.on('onSomething', () => null); 57 | expect(send).toHaveBeenCalledWith('nvim:write', 3, 'nvim_subscribe', ['onSomething']); 58 | }); 59 | 60 | test('does not subscribe twice on the same event', () => { 61 | nvim.on('onSomething', () => null); 62 | nvim.on('onSomething', () => null); 63 | expect(send).toHaveBeenCalledWith('nvim:write', 3, 'nvim_subscribe', ['onSomething']); 64 | expect(send).toHaveBeenCalledTimes(1); 65 | }); 66 | 67 | test('send `nvim_unsubscribe` when you subscribe', () => { 68 | const listener = () => null; 69 | nvim.on('onSomething', listener); 70 | nvim.removeListener('onSomething', listener); 71 | expect(send).toHaveBeenCalledWith('nvim:write', 5, 'nvim_unsubscribe', ['onSomething']); 72 | }); 73 | 74 | test('does not unsubscribe if you have events with that name', () => { 75 | const listener = () => null; 76 | const anotherListener = () => null; 77 | nvim.on('onSomething', listener); 78 | nvim.on('onSomething', anotherListener); 79 | nvim.removeListener('onSomething', listener); 80 | expect(send).not.toHaveBeenCalledWith('nvim:write', 5, 'nvim_unsubscribe', ['onSomething']); 81 | }); 82 | 83 | test('receives notification for subscription', () => { 84 | const callback = jest.fn(); 85 | nvim.on('onSomething', callback); 86 | transportMock.emit('nvim:data', [2, 'onSomething', 'params1']); 87 | expect(callback).toHaveBeenCalledWith('params1'); 88 | transportMock.emit('nvim:data', [2, 'onSomething', 'params2']); 89 | expect(callback).toHaveBeenCalledWith('params2'); 90 | }); 91 | 92 | test('does not receives notifications that are not subscribed', () => { 93 | const callback = jest.fn(); 94 | nvim.on('onSomething', callback); 95 | transportMock.emit('nvim:data', [2, 'onSomethingElse', 'params1']); 96 | expect(callback).not.toHaveBeenCalled(); 97 | }); 98 | }); 99 | 100 | describe('request message type', () => { 101 | test('receives result of request', async () => { 102 | const errorSpy = jest.spyOn(console, 'error').mockImplementationOnce(() => { 103 | /* empty */ 104 | }); 105 | transportMock.emit('nvim:data', [0]); 106 | expect(errorSpy).toHaveBeenCalled(); 107 | }); 108 | }); 109 | 110 | describe('predefined commands', () => { 111 | const commands = [ 112 | ['subscribe', 'subscribe'], 113 | ['unsubscribe', 'unsubscribe'], 114 | ['callFunction', 'call_function'], 115 | ['command', 'command'], 116 | ['input', 'input'], 117 | ['inputMouse', 'input_mouse'], 118 | ['getMode', 'get_mode'], 119 | ['uiTryResize', 'ui_try_resize'], 120 | ['uiAttach', 'ui_attach'], 121 | ['getHlByName', 'get_hl_by_name'], 122 | ['paste', 'paste'], 123 | ] as const; 124 | commands.forEach(([command, request]) => { 125 | test(`${command}`, () => { 126 | nvim = new Nvim(transportMock); 127 | nvim[command]('param1', 'param2'); 128 | expect(send).toHaveBeenCalledWith('nvim:write', 3, `nvim_${request}`, ['param1', 'param2']); 129 | }); 130 | }); 131 | 132 | test('eval', () => { 133 | nvim = new Nvim(transportMock); 134 | nvim.eval('param1'); 135 | expect(send).toHaveBeenCalledWith('nvim:write', 3, `nvim_eval`, ['param1']); 136 | }); 137 | 138 | test('getShortMode returns mode', async () => { 139 | const resultPromise = nvim.getShortMode(); 140 | transportMock.emit('nvim:data', [1, 3, null, { mode: 'n' }]); 141 | expect(await resultPromise).toBe('n'); 142 | }); 143 | 144 | test('getShortMode cut CTRL- from mode', async () => { 145 | const resultPromise = nvim.getShortMode(); 146 | transportMock.emit('nvim:data', [1, 3, null, { mode: 'CTRL-n' }]); 147 | expect(await resultPromise).toBe('n'); 148 | }); 149 | }); 150 | 151 | test('emit `close` when transport emits `nvim:close`', () => { 152 | const callback1 = jest.fn(); 153 | const callback2 = jest.fn(); 154 | 155 | nvim.on('close', callback1); 156 | nvim.on('close', callback2); 157 | 158 | transportMock.emit('nvim:close'); 159 | 160 | expect(callback1).toHaveBeenCalled(); 161 | expect(callback2).toHaveBeenCalled(); 162 | }); 163 | }); 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VV 2 | 3 | VV is a Neovim client for macOS. A pure, fast, minimalistic Vim experience with good macOS integration. Optimized for speed and nice font rendering. 4 | 5 | ![VV screenshot](packages/electron/assets/screenshot.png) 6 | 7 | - Fast text render via WebGL. 8 | - OS integration: copy/paste, mouse, scroll. 9 | - Fullscreen support for native and simple (fast) mode. 10 | - All app settings configurable via Vimscript. 11 | - Command line launcher. 12 | - “Save All” dialog on quit and “Refresh” dialog on external changes. 13 | - Text zoom. 14 | 15 | VV is built on Electron. There are no barriers to porting it to Windows or Linux, or making plugins with Javascript, HTML, and CSS. 16 | 17 | ## Installation 18 | 19 | ### Install via Homebrew 20 | 21 | VV is available via Homebrew Cask: 22 | 23 | ``` 24 | $ brew install vv 25 | ``` 26 | 27 | NOTE: older versions of brew require a special command to install `vv` 28 | 29 | ``` 30 | $ brew cask install vv 31 | ``` 32 | 33 | It will also install Neovim (if it is not installed) and command line launcher `vv`. 34 | 35 | ### Download 36 | 37 | Or you can download the most recent release from the [Releases](https://github.com/vv-vim/vv/releases/latest) page. 38 | 39 | 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. 40 | 41 | ### Build manually 42 | 43 | You can also build it manually. You will need [Node.js](https://nodejs.org/en/download/) and [Yarn](https://yarnpkg.com/lang/en/) installed. 44 | 45 | ``` 46 | $ git clone git@github.com:vv-vim/vv.git 47 | $ cd vv 48 | $ yarn 49 | $ yarn build:electron 50 | ``` 51 | 52 | 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`. 53 | 54 | ## Command Line Launcher 55 | 56 | 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: 57 | 58 | ``` 59 | ln -s -f /Applications/VV.app/Contents/Resources/bin/vv [dir from $PATH]/vv 60 | ``` 61 | 62 | Usage: `vv [options] [file ...]` 63 | 64 | Options are passed to `nvim`. You can check available options in nvim help: `nvim --help`. 65 | 66 | ## Settings 67 | 68 | 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. 69 | 70 | - `fullscreen`, `fu`: Switch to fullscreen mode. You can also toggle fullscreen with `Cmd+Ctrl+F`. Default: `0`. 71 | - `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`. 72 | - `bold`: Allow bold font. You can completely disable bold even if the colorscheme uses it. Default: `1`. 73 | - `italic`: Allow italic. Default: `1`. 74 | - `underline`: Allow underline. Default: `1`. 75 | - `undercurl`: Allow undercurl. Default: `1`. 76 | - `strikethrough`: Allow strikethrough. Default: `1`. 77 | - `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`. 78 | - `fontsize`: Font size in pixels. Default: `12`. 79 | - `lineheight`: Line height related to font size. Pixel value is `fontsize * lineheight`. Default: `1.25`. 80 | - `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`. 81 | - `windowwidth`, `width`: Window width. Can be a number in pixels or percentage of display width. 82 | - `windowheight`, `height`: Window height. 83 | - `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. 84 | - `windowtop`, `top`: Window position top. 85 | - `quitoncloselastwindow`: Quit app on close last window. Default: `0`. 86 | - `autoupdateinterval`: Autoupdate interval in minutes. `0` — disable autoupdate. Default: `1440`, one day. 87 | - `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`. 88 | 89 | 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: 90 | 91 | ``` 92 | if exists('g:vv') 93 | VVset nobold 94 | VVset noitalic 95 | VVset windowheight=100% 96 | VVset windowwidth=60% 97 | VVset windowleft=0 98 | VVset windowtop=0 99 | endif 100 | ``` 101 | 102 | VV also sets `set termguicolors` on startup. 103 | 104 | ## Development 105 | 106 | First, you need start a Webpack watch process in a separate terminal: 107 | 108 | ``` 109 | yarn dev 110 | ``` 111 | 112 | Then you can run the app: 113 | 114 | ``` 115 | yarn start:electron 116 | ``` 117 | 118 | You can run tests with `yarn test` and ESLint with `yarn lint` commands. 119 | 120 | 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`. 121 | 122 | ## Server 123 | 124 | You can run Neovim remotely in browser via VV Server. More info: [packages/server/README.md](packages/server/README.md) 125 | 126 | ## Browser Renderer 127 | 128 | [Browser Renderer](packages/browser-renderer/README.md) is a separate package used in Electron app and Server. 129 | 130 | ## Name 131 | 132 | The VV name comes from the bash shortcut `vv` that I use to start Vim. 133 | 134 | ## License 135 | 136 | VV is released under the [MIT License](https://opensource.org/licenses/MIT). 137 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/input/keyboard.ts: -------------------------------------------------------------------------------- 1 | import type { Nvim } from '@vvim/nvim'; 2 | import { Screen } from 'src/screen'; 3 | 4 | // :help keyCode 5 | const specialKey = ({ key, code }: KeyboardEvent): string => 6 | (({ 7 | Insert: 'Insert', 8 | Numpad0: 'k0', 9 | Numpad1: 'k1', 10 | Numpad2: 'k2', 11 | Numpad3: 'k3', 12 | Numpad4: 'k4', 13 | Numpad5: 'k5', 14 | Numpad6: 'k6', 15 | Numpad7: 'k7', 16 | Numpad8: 'k8', 17 | Numpad9: 'k9', 18 | NumpadAdd: 'kPlus', 19 | NumpadSubtract: 'kMinus', 20 | NumpadMultiply: 'kMultiply', 21 | NumpadDivide: 'kDivide', 22 | NumpadEnter: 'kEnter', 23 | NumpadDecimal: 'kPoint', 24 | Escape: 'Esc', 25 | Backspace: 'BS', 26 | Delete: 'Del', 27 | Enter: 'CR', 28 | Tab: 'Tab', 29 | ArrowUp: 'Up', 30 | ArrowDown: 'Down', 31 | ArrowLeft: 'Left', 32 | ArrowRight: 'Right', 33 | PageUp: 'PageUp', 34 | PageDown: 'PageDown', 35 | Home: 'Home', 36 | End: 'End', 37 | F1: 'F1', 38 | F2: 'F2', 39 | F3: 'F3', 40 | F4: 'F4', 41 | F5: 'F5', 42 | F6: 'F6', 43 | F7: 'F7', 44 | F8: 'F8', 45 | F9: 'F9', 46 | F10: 'F10', 47 | F11: 'F11', 48 | F12: 'F12', 49 | } as Record)[code] || 50 | ({ 51 | '<': 'lt', 52 | '\\': 'Bslash', 53 | '|': 'Bar', 54 | } as Record)[key]); 55 | 56 | const skip = (key: string) => 57 | (({ 58 | Shift: true, 59 | Control: true, 60 | Alt: true, 61 | Meta: true, 62 | CapsLock: true, 63 | } as Record)[key]); 64 | 65 | export const modifierPrefix = ( 66 | { metaKey, altKey, ctrlKey }: KeyboardEvent | MouseEvent, 67 | insertMode?: boolean, 68 | ): string => { 69 | if (insertMode && altKey && !ctrlKey && !metaKey) { 70 | return ''; 71 | } 72 | return `${metaKey ? 'D-' : ''}${altKey ? 'A-' : ''}${ctrlKey ? 'C-' : ''}`; 73 | }; 74 | 75 | export const shiftPrefix = ({ shiftKey, key }: KeyboardEvent): string => 76 | shiftKey && key !== '<' ? 'S-' : ''; 77 | 78 | /** 79 | * Filter hotkeys from menu. 80 | * TODO: Make it customizable and make it work differently in browser and electron app. 81 | */ 82 | const filterResult = (result: string) => 83 | !({ 84 | '': true, // Cmd+C 85 | '': true, // Cmd+V 86 | '': true, // Cmd+A: "Select all" menu item 87 | '': true, // Cmd+Plus: "Zoom In" menu item 88 | '': true, // Cmd+-: "Zoom Out" menu item 89 | '': true, // Cmd+0: "Actual Size" menu item 90 | '': true, // Cmd+Ctrl+F: "Toggle Full Screen" menu item 91 | '': true, // Cmd+M: "Minimize" menu item 92 | '': true, // Cmd+H: Hide window 93 | '': true, // Cmd+Q: Quit 94 | '': true, // Cmd+O: Open file 95 | '': true, // Cmd+N: New window 96 | '': true, // Cmd+W: Close window 97 | } as Record)[result] && result; 98 | 99 | // https://github.com/rhysd/NyaoVim/issues/87 100 | const replaceResult = (result: string) => 101 | (({ 102 | '': '', 103 | '': '', 104 | '': '', 105 | } as Record)[result] || result); 106 | 107 | const eventKeyCode = (event: KeyboardEvent, insertMode?: boolean): string | null => { 108 | const { key } = event; 109 | 110 | if (skip(key)) return null; 111 | 112 | // Handle Alt + modifier key input (for example Alt + i) 113 | let deadKey; 114 | if (key === 'Dead') { 115 | if (!insertMode && event.altKey && event.code.match(/^Key[A-Z]$/)) { 116 | deadKey = event.code[3].toLowerCase(); 117 | } else { 118 | return null; 119 | } 120 | } 121 | 122 | const modifier = modifierPrefix(event, insertMode); 123 | const shift = shiftPrefix(event); 124 | const special = specialKey(event); 125 | 126 | const keyCode = deadKey || special || key; 127 | 128 | const result = modifier || special ? `<${modifier}${shift}${keyCode}>` : keyCode; 129 | 130 | const filteredResult = filterResult(result); 131 | if (!filteredResult) { 132 | return null; 133 | } 134 | 135 | return replaceResult(filteredResult); 136 | }; 137 | 138 | const initKeyboard = ({ nvim, screen }: { nvim: Nvim; screen: Screen }): void => { 139 | const { getCursorElement } = screen; 140 | 141 | let disableNextInput = false; 142 | let inputKey: string | null = null; 143 | let isComposing = false; 144 | let compositionValue = null; 145 | let insertMode = false; 146 | 147 | const input = document.createElement('input'); 148 | 149 | input.style.position = 'absolute'; 150 | input.style.opacity = '0'; 151 | input.style.left = '0'; 152 | input.style.top = '0'; 153 | input.style.width = '0'; 154 | input.style.height = '0'; 155 | 156 | (getCursorElement() || document.getElementsByTagName('body')[0]).appendChild(input); 157 | 158 | const handleKeydown = async (event: KeyboardEvent) => { 159 | disableNextInput = true; 160 | if (!isComposing) { 161 | inputKey = eventKeyCode(event, insertMode); 162 | if (inputKey) { 163 | nvim.input(inputKey); 164 | } 165 | } 166 | }; 167 | 168 | // Non-keyboard input. For example insert emoji. 169 | const handleInput = (event: InputEvent) => { 170 | if (disableNextInput || isComposing) { 171 | disableNextInput = false; 172 | return; 173 | } 174 | if (event.data) { 175 | nvim.input(event.data); 176 | } 177 | }; 178 | 179 | // Composition input for logograms or diacritical signs. Also works for speech input. 180 | const handleCompositionStart = () => { 181 | isComposing = true; 182 | compositionValue = inputKey || ''; 183 | }; 184 | 185 | const handleCompositionEnd = () => { 186 | isComposing = false; 187 | }; 188 | 189 | const handleCompositionUpdate = (event: CompositionEvent) => { 190 | nvim.input(`${''.repeat(compositionValue.length)}${event.data}`); 191 | compositionValue = event.data; 192 | }; 193 | 194 | document.addEventListener('keydown', handleKeydown); 195 | 196 | // @ts-expect-error input event type is incorrect 197 | input.addEventListener('input', handleInput); 198 | input.addEventListener('compositionstart', handleCompositionStart); 199 | input.addEventListener('compositionupdate', handleCompositionUpdate); 200 | input.addEventListener('compositionend', handleCompositionEnd); 201 | 202 | // Enable composition input only for insert and command-line modes. Enabling if for other modes 203 | // is tricky. `preventDefault` does not work for compositionstart, so we need to blur/focus input 204 | // element for this. 205 | nvim.on('redraw', (args) => { 206 | args.forEach((arg) => { 207 | if (arg[0] === 'mode_change') { 208 | const [mode] = arg[1]; 209 | // https://github.com/neovim/neovim/blob/master/src/nvim/cursor_shape.c#L18 210 | if (['insert', 'cmdline_normal'].includes(mode)) { 211 | insertMode = true; 212 | input.focus(); 213 | } else { 214 | insertMode = false; 215 | input.blur(); 216 | } 217 | } 218 | }); 219 | }); 220 | }; 221 | 222 | export default initKeyboard; 223 | -------------------------------------------------------------------------------- /packages/browser-renderer/src/input/__tests__/keyboard.test.ts: -------------------------------------------------------------------------------- 1 | import initKeyboard from 'src/input/keyboard'; 2 | 3 | import Nvim from '@vvim/nvim'; 4 | import { Screen } from 'src/screen'; 5 | 6 | describe('Keyboard input', () => { 7 | const nvimOn = jest.fn(); 8 | 9 | const screen = ({ 10 | getCursorElement: jest.fn(), 11 | } as unknown) as Screen; 12 | 13 | const nvim = ({ 14 | input: jest.fn(), 15 | on: nvimOn, 16 | } as unknown) as Nvim; 17 | 18 | const simulateKeyDown = (options: KeyboardEventInit) => { 19 | const event = new KeyboardEvent('keydown', options); 20 | document.dispatchEvent(event); 21 | }; 22 | 23 | const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); 24 | 25 | beforeEach(() => { 26 | initKeyboard({ screen, nvim }); 27 | }); 28 | 29 | afterEach(() => { 30 | const rootElm = document.documentElement; 31 | rootElm.innerHTML = ''; 32 | addEventListenerSpy.mock.calls.forEach(([event, callback, options]) => 33 | document.removeEventListener(event, callback, options), 34 | ); 35 | }); 36 | 37 | describe('key input', () => { 38 | test('sends key value to nvim input', () => { 39 | simulateKeyDown({ key: 'i' }); 40 | expect(nvim.input).toHaveBeenCalledWith('i'); 41 | expect(nvim.input).toHaveBeenCalledTimes(1); 42 | }); 43 | 44 | test('special key', () => { 45 | simulateKeyDown({ code: 'Insert' }); 46 | expect(nvim.input).toHaveBeenCalledWith(''); 47 | expect(nvim.input).toHaveBeenCalledTimes(1); 48 | }); 49 | 50 | test('special key with Shift', () => { 51 | simulateKeyDown({ code: 'Insert', shiftKey: true }); 52 | expect(nvim.input).toHaveBeenCalledWith(''); 53 | expect(nvim.input).toHaveBeenCalledTimes(1); 54 | }); 55 | 56 | test('special key with modifier', () => { 57 | simulateKeyDown({ code: 'Insert', altKey: true }); 58 | expect(nvim.input).toHaveBeenCalledWith(''); 59 | expect(nvim.input).toHaveBeenCalledTimes(1); 60 | }); 61 | 62 | test('< key', () => { 63 | simulateKeyDown({ key: '<', shiftKey: true }); 64 | expect(nvim.input).toHaveBeenCalledWith(''); 65 | expect(nvim.input).toHaveBeenCalledTimes(1); 66 | }); 67 | 68 | test('\\ key', () => { 69 | simulateKeyDown({ key: '\\' }); 70 | expect(nvim.input).toHaveBeenCalledWith(''); 71 | expect(nvim.input).toHaveBeenCalledTimes(1); 72 | }); 73 | 74 | test('| key', () => { 75 | simulateKeyDown({ key: '|' }); 76 | expect(nvim.input).toHaveBeenCalledWith(''); 77 | expect(nvim.input).toHaveBeenCalledTimes(1); 78 | }); 79 | 80 | test.todo('TODO: test all special keys'); 81 | }); 82 | 83 | describe('motifiers', () => { 84 | test('CTRL key adds modifier', () => { 85 | simulateKeyDown({ key: 'i', ctrlKey: true }); 86 | expect(nvim.input).toHaveBeenCalledWith(''); 87 | expect(nvim.input).toHaveBeenCalledTimes(1); 88 | }); 89 | 90 | test('Option key adds modifier', () => { 91 | simulateKeyDown({ key: 'i', altKey: true }); 92 | expect(nvim.input).toHaveBeenCalledWith(''); 93 | expect(nvim.input).toHaveBeenCalledTimes(1); 94 | }); 95 | 96 | test('CMD key adds modifier', () => { 97 | simulateKeyDown({ key: 'i', metaKey: true }); 98 | expect(nvim.input).toHaveBeenCalledWith(''); 99 | expect(nvim.input).toHaveBeenCalledTimes(1); 100 | }); 101 | 102 | test('Shift key does not add modifier without other motifiers', () => { 103 | simulateKeyDown({ key: 'I', shiftKey: true }); 104 | expect(nvim.input).toHaveBeenCalledWith('I'); 105 | expect(nvim.input).toHaveBeenCalledTimes(1); 106 | }); 107 | 108 | test('Shift adds modifier with other motifiers', () => { 109 | simulateKeyDown({ key: 'i', ctrlKey: true, shiftKey: true }); 110 | expect(nvim.input).toHaveBeenCalledWith(''); 111 | expect(nvim.input).toHaveBeenCalledTimes(1); 112 | }); 113 | 114 | test('multiple motifiers', () => { 115 | simulateKeyDown({ key: 'i', ctrlKey: true, metaKey: true, altKey: true, shiftKey: true }); 116 | expect(nvim.input).toHaveBeenCalledWith(''); 117 | expect(nvim.input).toHaveBeenCalledTimes(1); 118 | }); 119 | }); 120 | 121 | describe('Option key modifier', () => { 122 | test("Map Dead key with Option to it's latin value", () => { 123 | simulateKeyDown({ key: 'Dead', code: 'KeyI', altKey: true }); 124 | expect(nvim.input).toHaveBeenCalledWith(''); 125 | expect(nvim.input).toHaveBeenCalledTimes(1); 126 | }); 127 | 128 | test('Skip Dead key if Option is not pressed', () => { 129 | simulateKeyDown({ key: 'Dead', code: 'KeyI' }); 130 | expect(nvim.input).toHaveBeenCalledTimes(0); 131 | }); 132 | 133 | test('Skip Dead key if there are no latin code for it', () => { 134 | simulateKeyDown({ key: 'Dead', code: 'NotKeyI', altKey: true }); 135 | expect(nvim.input).toHaveBeenCalledTimes(0); 136 | }); 137 | 138 | test('Adds A- modifier for non-Dead key', () => { 139 | simulateKeyDown({ key: '∆', altKey: true }); 140 | expect(nvim.input).toHaveBeenCalledWith(''); 141 | expect(nvim.input).toHaveBeenCalledTimes(1); 142 | }); 143 | 144 | describe('with input mode', () => { 145 | beforeEach(() => { 146 | nvimOn.mock.calls[0][1]([['mode_change', ['insert']]]); 147 | }); 148 | 149 | test('Does not add A- modifier', () => { 150 | simulateKeyDown({ key: '∆', code: 'KeyJ', altKey: true }); 151 | expect(nvim.input).toHaveBeenCalledWith('∆'); 152 | expect(nvim.input).toHaveBeenCalledTimes(1); 153 | }); 154 | 155 | test('Does not add A- modifier with Shift', () => { 156 | simulateKeyDown({ key: 'Ô', code: 'KeyJ', altKey: true, shiftKey: true }); 157 | expect(nvim.input).toHaveBeenCalledWith('Ô'); 158 | expect(nvim.input).toHaveBeenCalledTimes(1); 159 | }); 160 | 161 | test('Adds A- modifier with Control', () => { 162 | simulateKeyDown({ key: '∆', code: 'KeyJ', altKey: true, ctrlKey: true }); 163 | expect(nvim.input).toHaveBeenCalledWith(''); 164 | expect(nvim.input).toHaveBeenCalledTimes(1); 165 | }); 166 | 167 | test('Adds A- modifier with Command', () => { 168 | simulateKeyDown({ key: '∆', code: 'KeyJ', altKey: true, metaKey: true }); 169 | expect(nvim.input).toHaveBeenCalledWith(''); 170 | expect(nvim.input).toHaveBeenCalledTimes(1); 171 | }); 172 | }); 173 | }); 174 | 175 | describe('focus input', () => { 176 | let input: HTMLInputElement; 177 | let focusSpy: jest.SpyInstance; 178 | let blurSpy: jest.SpyInstance; 179 | 180 | beforeEach(() => { 181 | input = document.getElementsByTagName('input')[0]; 182 | focusSpy = jest.spyOn(input, 'focus'); 183 | blurSpy = jest.spyOn(input, 'blur'); 184 | }); 185 | 186 | test('focus input on insert mode', () => { 187 | nvimOn.mock.calls[0][1]([['mode_change', ['insert']]]); 188 | expect(focusSpy).toHaveBeenCalled(); 189 | }); 190 | 191 | test('focus input on cmdline_normal mode', () => { 192 | nvimOn.mock.calls[0][1]([['mode_change', ['cmdline_normal']]]); 193 | expect(focusSpy).toHaveBeenCalled(); 194 | }); 195 | 196 | test('blurs input on other modes', () => { 197 | nvimOn.mock.calls[0][1]([['mode_change', ['normal']]]); 198 | expect(blurSpy).toHaveBeenCalled(); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /packages/electron/src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, dialog } from 'electron'; 2 | import { statSync, existsSync } from 'fs'; 3 | import { join, resolve } from 'path'; 4 | 5 | import isDev from 'src/lib/isDev'; 6 | 7 | import menu from 'src/main/menu'; 8 | import installCli from 'src/main/installCli'; 9 | import checkNeovim from 'src/main/checkNeovim'; 10 | 11 | import { setShouldQuit } from 'src/main/nvim/features/quit'; 12 | import { getSettings } from 'src/main/nvim/settings'; 13 | import { getNvimByWindow } from 'src/main/nvim/nvimByWindow'; 14 | 15 | import initAutoUpdate from 'src/main/autoUpdate'; 16 | 17 | import initNvim from 'src/main/nvim/nvim'; 18 | import { parseArgs, joinArgs, filterArgs, cliArgs, argValue } from 'src/main/lib/args'; 19 | 20 | import IpcTransport from 'src/main/transport/ipc'; 21 | 22 | let currentWindow: BrowserWindow | undefined | null; 23 | 24 | const windows: BrowserWindow[] = []; 25 | 26 | /** Empty windows created in advance to make windows creation faster */ 27 | const emptyWindows: BrowserWindow[] = []; 28 | 29 | app.commandLine.appendSwitch('force_high_performance_gpu'); 30 | 31 | const openDeveloperTools = (win: BrowserWindow) => { 32 | win.webContents.openDevTools({ mode: 'detach' }); 33 | win.webContents.on('devtools-opened', () => { 34 | win.webContents.focus(); 35 | }); 36 | }; 37 | 38 | const handleAllClosed = () => { 39 | const { quitoncloselastwindow } = getSettings(); 40 | if (quitoncloselastwindow || process.platform !== 'darwin') { 41 | app.quit(); 42 | } 43 | }; 44 | 45 | const createEmptyWindow = (isDebug = false) => { 46 | const options = { 47 | width: 800, 48 | height: 600, 49 | show: isDebug, 50 | fullscreenable: false, 51 | // frame: false, 52 | // roundedCorners: false, 53 | webPreferences: { 54 | preload: join(app.getAppPath(), isDev('./', '../'), 'src/main/preload.js'), 55 | }, 56 | }; 57 | let win = new BrowserWindow(options); 58 | // @ts-expect-error TODO 59 | win.zoomLevel = 0; 60 | 61 | win.on('closed', async () => { 62 | if (currentWindow === win) currentWindow = null; 63 | 64 | const i = windows.indexOf(win); 65 | if (i !== -1) windows.splice(i, 1); 66 | // @ts-expect-error TODO 67 | win = null; 68 | 69 | if (windows.length === 0) handleAllClosed(); 70 | }); 71 | 72 | win.on('focus', () => { 73 | currentWindow = win; 74 | }); 75 | 76 | win.loadURL( 77 | process.env.DEV_SERVER ? 'http://localhost:3000' : `file://${join(__dirname, './index.html')}`, 78 | ); 79 | 80 | return win; 81 | }; 82 | 83 | const getEmptyWindow = (isDebug = false): BrowserWindow => { 84 | if (emptyWindows.length > 0) { 85 | return emptyWindows.pop() as BrowserWindow; 86 | } 87 | return createEmptyWindow(isDebug); 88 | }; 89 | 90 | const createWindow = async (originalArgs: string[] = [], newCwd?: string) => { 91 | const settings = getSettings(); 92 | const cwd = newCwd || process.cwd(); 93 | 94 | const isDebug = originalArgs.includes('--debug') || originalArgs.includes('--inspect'); 95 | // TODO: Use yargs maybe. 96 | const { args, files } = parseArgs(filterArgs(originalArgs)); 97 | let unopenedFiles = files; 98 | 99 | let { openInProject } = settings; 100 | let openInProjectArg = argValue(originalArgs, '--open-in-project'); 101 | if (openInProjectArg === '0' || openInProjectArg === 'false') { 102 | openInProjectArg = undefined; 103 | openInProject = 0; 104 | } 105 | if (openInProjectArg === 'true') { 106 | openInProjectArg = '1'; 107 | } 108 | 109 | // TODO: Rafactor this somewhere to a separate file or function. 110 | if (openInProject || openInProjectArg) { 111 | await Promise.all( 112 | windows.map(async (win) => { 113 | const nvim = getNvimByWindow(win); 114 | if (nvim) { 115 | // @ts-expect-error TODO: don't add custom props to win 116 | win.cwd = await nvim.callFunction('VVprojectRoot', []); // eslint-disable-line 117 | } 118 | return Promise.resolve(); 119 | }), 120 | ); 121 | unopenedFiles = files.reduce((result, fileName) => { 122 | const resolvedFileName = resolve(cwd, fileName); 123 | const openInWindow = windows.find( 124 | // @ts-expect-error TODO: don't add custom props to win 125 | (w) => resolvedFileName.startsWith(w.cwd) && !w.isMinimized(), 126 | ); 127 | if (openInWindow) { 128 | const nvim = getNvimByWindow(openInWindow); 129 | if (nvim) { 130 | // @ts-expect-error TODO: don't add custom props to win 131 | const relativeFileName = resolvedFileName.substring(openInWindow.cwd.length + 1); 132 | nvim.callFunction( 133 | 'VVopenInProject', 134 | openInProjectArg ? [relativeFileName, openInProjectArg] : [relativeFileName], 135 | ); 136 | openInWindow.focus(); 137 | app.focus({ steal: true }); 138 | return result; 139 | } 140 | } 141 | return [...result, fileName]; 142 | }, []); 143 | } 144 | 145 | if (files.length === 0 || unopenedFiles.length > 0) { 146 | const win = getEmptyWindow(isDebug); 147 | 148 | // @ts-expect-error TODO: don't add custom props to win 149 | win.cwd = cwd; 150 | 151 | if (currentWindow && !currentWindow.isFullScreen() && !currentWindow.isSimpleFullScreen()) { 152 | const [x, y] = currentWindow.getPosition(); 153 | const [width, height] = currentWindow.getSize(); 154 | win.setBounds({ x: x + 20, y: y + 20, width, height }, false); 155 | } 156 | 157 | const transport = new IpcTransport(win); 158 | 159 | initNvim({ 160 | args: joinArgs({ args, files: unopenedFiles }), 161 | cwd, 162 | win, 163 | transport, 164 | }); 165 | 166 | const initRenderer = () => transport.send('initRenderer', settings); 167 | 168 | if (win.webContents.isLoading()) { 169 | win.webContents.on('did-finish-load', initRenderer); 170 | } else { 171 | initRenderer(); 172 | } 173 | 174 | win.focus(); 175 | windows.push(win); 176 | 177 | if (isDebug) { 178 | openDeveloperTools(win); 179 | } else { 180 | setTimeout(() => emptyWindows.push(createEmptyWindow()), 1000); 181 | } 182 | 183 | initAutoUpdate({ win }); 184 | } 185 | }; 186 | 187 | const openFileOrDir = (fileName: string) => { 188 | app.addRecentDocument(fileName); 189 | if (existsSync(fileName) && statSync(fileName).isDirectory()) { 190 | createWindow([fileName], fileName); 191 | } else { 192 | createWindow([fileName]); 193 | } 194 | }; 195 | 196 | const openFile = () => { 197 | const fileNames = dialog.showOpenDialogSync({ 198 | properties: ['openFile', 'openDirectory', 'createDirectory', 'multiSelections'], 199 | }); 200 | if (fileNames) { 201 | fileNames.forEach(openFileOrDir); 202 | } 203 | }; 204 | 205 | const gotTheLock = isDev() || app.requestSingleInstanceLock(); 206 | 207 | if (!gotTheLock) { 208 | app.quit(); 209 | } else { 210 | let fileToOpen: string | undefined | null; 211 | app.on('will-finish-launching', () => { 212 | app.on('open-file', (_e, file) => { 213 | fileToOpen = file; 214 | }); 215 | }); 216 | 217 | app.on('ready', () => { 218 | checkNeovim(); 219 | if (fileToOpen) { 220 | openFileOrDir(fileToOpen); 221 | fileToOpen = null; 222 | } else { 223 | createWindow(cliArgs()); 224 | } 225 | menu({ 226 | createWindow, 227 | openFile, 228 | installCli: installCli(join(app.getAppPath(), '../bin/vv')), 229 | }); 230 | app.on('open-file', (_e, file) => openFileOrDir(file)); 231 | 232 | app.focus(); 233 | }); 234 | 235 | app.on('second-instance', (_e, args, cwd) => { 236 | createWindow(cliArgs(args), cwd); 237 | }); 238 | 239 | app.on('before-quit', (e) => { 240 | setShouldQuit(true); 241 | const visibleWindows = windows.filter((w) => w.isVisible()); 242 | if (visibleWindows.length > 0) { 243 | e.preventDefault(); 244 | (currentWindow || visibleWindows[0]).close(); 245 | } 246 | }); 247 | 248 | app.on('window-all-closed', handleAllClosed); 249 | 250 | app.on('activate', (_e, hasVisibleWindows) => { 251 | if (!hasVisibleWindows) { 252 | createWindow(); 253 | } 254 | }); 255 | } 256 | --------------------------------------------------------------------------------