├── .eslintrc.js ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── build.yml │ └── deploy.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── package-lock.json ├── package.json ├── src ├── bindingComparer.ts ├── charCode.ts ├── commandRelay.ts ├── config │ ├── bindingItem.ts │ ├── condition.ts │ ├── menuConfig.ts │ └── whichKeyConfig.ts ├── constants.ts ├── dispatchQueue.ts ├── extension.ts ├── menu │ ├── baseWhichKeyMenu.ts │ ├── descBindMenu.ts │ ├── descBindMenuItem.ts │ ├── repeaterMenu.ts │ ├── transientMenu.ts │ └── whichKeyMenu.ts ├── statusBar.ts ├── test │ ├── runTest-node.ts │ ├── runTest-web.ts │ └── suite │ │ ├── dispatchQueue.test.ts │ │ ├── extension.test.ts │ │ ├── index-node.ts │ │ ├── index-web.ts │ │ ├── menu │ │ └── whichkeyMenu.test.ts │ │ └── testUtils.ts ├── utils.ts ├── version.ts ├── whichKeyCommand.ts ├── whichKeyRegistry.ts └── whichKeyRepeater.ts ├── tsconfig.json └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /**@type {import('eslint').Linter.Config} */ 2 | // eslint-disable-next-line no-undef 3 | module.exports = { 4 | root: true, 5 | env: { 6 | es6: true, 7 | node: true, 8 | mocha: true, 9 | }, 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["@typescript-eslint"], 12 | extends: [ 13 | "eslint:recommended", 14 | "plugin:@typescript-eslint/recommended", 15 | "prettier", 16 | ], 17 | rules: { 18 | semi: [2, "always"], 19 | "eol-last": "error", 20 | "@typescript-eslint/no-unused-vars": 0, 21 | "@typescript-eslint/no-var-requires": 0, 22 | "@typescript-eslint/no-explicit-any": 0, 23 | "@typescript-eslint/explicit-module-boundary-types": 0, 24 | "@typescript-eslint/no-non-null-assertion": 0, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | tags-ignore: 6 | - "**" 7 | pull_request: 8 | 9 | name: Build Extension 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout vscode-which-key 15 | uses: actions/checkout@v2 16 | 17 | - name: Set up node.js 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: "lts/*" 21 | 22 | - name: Install dependencies 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: npm run lint 27 | 28 | - name: Format check 29 | run: npm run format-check 30 | 31 | - name: Test 32 | timeout-minutes: 30 33 | run: xvfb-run -a npm run test 34 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - v* 5 | 6 | name: Deploy Extension 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout vscode-which-key 12 | uses: actions/checkout@v2 13 | 14 | - name: Set up node.js 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: "lts/*" 18 | 19 | - name: Install dependencies 20 | uses: bahmutov/npm-install@v1 21 | 22 | - name: Lint 23 | run: npm run lint 24 | 25 | - name: Format check 26 | run: npm run format-check 27 | 28 | - name: Test 29 | timeout-minutes: 30 30 | run: xvfb-run -a npm run test 31 | 32 | - name: Package 33 | id: package 34 | run: | 35 | npx vsce package; 36 | echo ::set-output name=vsix_path::$(ls *.vsix) 37 | 38 | - name: Create release on GitHub 39 | id: create_release 40 | uses: actions/create-release@v1 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | with: 44 | tag_name: ${{ github.ref }} 45 | release_name: ${{ github.ref }} 46 | draft: false 47 | prerelease: false 48 | 49 | - name: Upload .vsix as release asset to GitHub 50 | uses: actions/upload-release-asset@v1 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | with: 54 | upload_url: ${{ steps.create_release.outputs.upload_url }} 55 | asset_path: ${{ steps.package.outputs.vsix_path }} 56 | asset_name: ${{ steps.package.outputs.vsix_path }} 57 | asset_content_type: application/zip 58 | 59 | - name: Publish to VSCode Extension Marketplace 60 | run: npx vsce publish --packagePath ${{ steps.package.outputs.vsix_path }} 61 | env: 62 | VSCE_PAT: ${{ secrets.VSCE_TOKEN }} 63 | 64 | - name: Publish to Open VSX Registry 65 | run: npx ovsx publish ${{ steps.package.outputs.vsix_path }} 66 | env: 67 | OVSX_PAT: ${{ secrets.OVSX_TOKEN }} 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out/ 2 | dist/ 3 | node_modules/ 4 | .vscode-test/ 5 | .vscode-test-web/ 6 | 7 | *.vsix 8 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out/ 2 | dist/ 3 | node_modules/ 4 | .vscode-test/ 5 | .vscode-test-web/ 6 | 7 | yarn.lock 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "useTabs": false, 4 | "singleQuote": false 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["dbaeumer.vscode-eslint"] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.1.0", 7 | "configurations": [ 8 | { 9 | "name": "Launch Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": ["--extensionDevelopmentPath=${workspaceRoot}"], 14 | "stopOnEntry": false, 15 | "sourceMaps": true, 16 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 17 | "preLaunchTask": "npm: watch" 18 | }, 19 | { 20 | "name": "Launch Tests", 21 | "type": "extensionHost", 22 | "request": "launch", 23 | "runtimeExecutable": "${execPath}", 24 | "args": [ 25 | "--extensionDevelopmentPath=${workspaceRoot}", 26 | "--extensionTestsPath=${workspaceRoot}/dist/test/suite/index-node" 27 | ], 28 | "stopOnEntry": false, 29 | "sourceMaps": true, 30 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 31 | "preLaunchTask": "npm: watch" 32 | }, 33 | { 34 | "name": "Run Web Extension in VS Code", 35 | "type": "pwa-extensionHost", 36 | "debugWebWorkerHost": true, 37 | "request": "launch", 38 | "args": [ 39 | "--extensionDevelopmentPath=${workspaceFolder}", 40 | "--extensionDevelopmentKind=web" 41 | ], 42 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 43 | "preLaunchTask": "npm: watch" 44 | }, 45 | { 46 | "name": "Extension Tests in VS Code", 47 | "type": "extensionHost", 48 | "debugWebWorkerHost": true, 49 | "request": "launch", 50 | "args": [ 51 | "--extensionDevelopmentPath=${workspaceFolder}", 52 | "--extensionDevelopmentKind=web", 53 | "--extensionTestsPath=${workspaceFolder}/dist/test/suite/index-web" 54 | ], 55 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 56 | "preLaunchTask": "npm: watch" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "[typescript]": { 12 | "editor.tabSize": 4 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "build", 9 | "group": "build", 10 | "problemMatcher": ["$ts-webpack", "$tslint-webpack"] 11 | }, 12 | { 13 | "type": "npm", 14 | "script": "watch", 15 | "group": "build", 16 | "isBackground": true, 17 | "problemMatcher": ["$ts-webpack-watch", "$tslint-webpack-watch"] 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .github/** 2 | 3 | .vscode/** 4 | .vscode-test/** 5 | .vscode-test-web/** 6 | 7 | **/*.ts 8 | **/*.map 9 | *.yml 10 | 11 | src/** 12 | out/** 13 | dist/test/** 14 | dist/**/*.map 15 | node_modules/** 16 | 17 | .gitignore 18 | .eslintrc.js 19 | tsconfig.json 20 | tslint.json 21 | webpack.config.js 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.11.4] - 2024-01-07 11 | 12 | ### Fixed 13 | 14 | - Fix typos in focus editor group commands 15 | 16 | ## [0.11.3] - 2021-12-19 17 | 18 | ### Fixed 19 | 20 | - Fix the issue where the new sort options are not effective. 21 | 22 | ## [0.11.2] - 2021-12-01 23 | 24 | ### Added 25 | 26 | - Add (`whichkey.useFullWidthCharacters`) to control whether to use full width characters as key in the which-key menu. 27 | 28 | ## [0.11.1] - 2021-11-30 29 | 30 | ### Fixed 31 | 32 | - Fix an issue where `when` in conditional bindings couldn't be evaluated since v0.10.0. ([VSpaceCode#256](https://github.com/VSpaceCode/VSpaceCode/issues/256)) 33 | 34 | ## [0.11.0] - 2021-11-29 35 | 36 | ### Added 37 | 38 | - Add command (`whichkey.undoKey`) to undo entered key. 39 | - Add menu buttons to which key menu (Use `whichkey.showButtons` in config to turn on/off). 40 | - Add three additional sorting options. 41 | 42 | - `custom`: 43 | Menu items are sorted by the key in the following 'categories' 44 | then by a custom order within each 'category'. 45 | 46 | The category order: 47 | 48 | 1. Single key (a, z, SPC, TAB, etc) 49 | 2. Function key (f11, F11, etc) 50 | 3. Modifier key (C-z, etc) 51 | 4. Others 52 | 53 | For the non-function key, the sort order of each character of the key: 54 | 55 | 1. SPC 56 | 2. Non-printable characters 57 | 3. DEL 58 | 4. ASCII symbols 59 | 5. Number 60 | 6. a-z 61 | 7. A-Z 62 | 8. Non-ASCII 63 | For function key, bindings will be sorted by the numeric order (e.g. F1, F2, F11, 12). 64 | 65 | - `customNonNumberFirst`: 66 | Menu items are sorted by bindings with non-number key first then by custom order. 67 | 68 | - `typeThenCustom`: 69 | Menu items are sorted by the binding type first then by custom order. 70 | 71 | ### Changed 72 | 73 | - Use full width character to render keys on the menu for better alignment. 74 | 75 | ### Fixed 76 | 77 | - Fix an issue where previous menu items are still getting rendered when delay is over. 78 | 79 | ## [0.10.0] - 2021-11-03 80 | 81 | ### Added 82 | 83 | - Support `display: hidden` in BindingItem and TransientBindingItem (#51). 84 | 85 | ### Changed 86 | 87 | - Reimplement which-key to improve key handling robustness. 88 | - Change error on status bar to be more prominent 89 | 90 | ## [0.9.3] - 2021-09-17 91 | 92 | ### Added 93 | 94 | - Add support for [web extension](https://code.visualstudio.com/api/extension-guides/web-extensions). 95 | 96 | ## [0.9.2] - 2021-06-27 97 | 98 | ### Changed 99 | 100 | - Add additional padding for icons. 101 | 102 | ## [0.9.1] - 2021-06-19 103 | 104 | ### Fixed 105 | 106 | - Fix an issue where using vim to call `whichkey.show` without any params failed. 107 | 108 | ### Changed 109 | 110 | - Update the name of ` r .` to be more descriptive. 111 | 112 | ## [0.9.0] - 2021-06-18 113 | 114 | ### Added 115 | 116 | - Add icons support for which-key menu ([#41](https://github.com/VSpaceCode/vscode-which-key/issues/41)). 117 | 118 | - Only vscode [product icon](https://code.visualstudio.com/api/references/icons-in-labels) can be used. 119 | - An extra property `icon` is used in the definition of which-key binding, which-key overrides, and transient binding. 120 | An example which-key binding with the icon property is as follow. 121 | 122 | ```json 123 | { 124 | "key": " ", 125 | "name": "Commands", 126 | "icon": "rocket", 127 | "type": "command", 128 | "command": "workbench.action.showCommands" 129 | } 130 | ``` 131 | 132 | - Implement the ability to search the bindings and execute a command (`whichkey.searchBindings`) where 133 | it can be triggered with ` ?` or `C-h` while the which-key is visible ([#12](https://github.com/VSpaceCode/vscode-which-key/issues/12)). 134 | - Add zen mode command (`whichkey.toggleZenMode`) to transient menu. 135 | 136 | - When the command is executed with a visible transient menu, all the menu items will be hidden/shown. 137 | - The command can be bound to the transient menu binding item or as a shortcut in your `keybindings.json` as follows. 138 | 139 | ```json 140 | { 141 | "key": "ctrl+z", 142 | "command": "whichkey.toggleZenMode", 143 | "when": "transientVisible" 144 | } 145 | ``` 146 | 147 | - No default shortcut is bound at the moment. 148 | - This is implemented to show more content of the buffer instead being block by the QuickPick menu. 149 | 150 | - Add repeater to record and repeat previously executed command ([#27](https://github.com/VSpaceCode/vscode-which-key/issues/27)). 151 | - Command `whichkey.repeatRecent` will show a which key menu to select all previous command executed in whichkey and bound to ` r .` by default. 152 | - Command `whichkey.repeatMostRecent` will execute the recently command executed in whichkey. 153 | 154 | ### Changed 155 | 156 | - Reimplemented transient menu as a separate command (`whichkey.showTransient`). 157 | - Existing transient defined as part of which-key menu with type `transient` is **deprecated** and will continue to work. 158 | - Remove redundant transient definition which allows sharing ([#13](https://github.com/VSpaceCode/vscode-which-key/issues/13)) by letting transient menu config/bindings to be defined in config. For example, calling `whichkey.showTransient` with `whichkey.transient.lineMoving` will mean that the transient config lives in `whichkey.transient.lineMoving`. 159 | - Add `exit` property in the binding definition to indicate that certain key in will exit on selection. 160 | - Add `transientVisible` context when transient is visible, and `whichkeyVisible` context will no longer be `true` when transient menu is visible. 161 | 162 | ### Removed 163 | 164 | - Remove `+` prefix from non-binding type in the default bindings. 165 | 166 | ## [0.8.6] - 2021-06-11 167 | 168 | ### Fixed 169 | 170 | - Properly fix QuickPick API change issue for vscode >= 1.57 (#34) 171 | 172 | ## [0.8.5] - 2021-05-12 173 | 174 | ### Fixed 175 | 176 | - Fix the name of ` g s` binding. 177 | - Fix vscode 1.57 insider QuickPick API change issue (#34). 178 | 179 | ## [0.8.4] - 2020-12-14 180 | 181 | ### Fixed 182 | 183 | - Fix the issue ` f f` doesn't work on non-Mac environment. 184 | 185 | ## [0.8.3] - 2020-12-11 186 | 187 | ### Changed 188 | 189 | - Indicate submenus with `+` instead of `...`. 190 | 191 | ## [0.8.2] - 2020-10-06 192 | 193 | ### Fixed 194 | 195 | - Fix the issue where `` key can not be use in the editor because the context `whichkeyVisible` is stuck in true if triggerKey command with an invalid key was called before the menu is displayed. 196 | - Fix the issue where key failed to append if the input was selected during the delay. The case was prominent when triggerKey was called before the menu is displayed (The key entered by triggerKey will be selected by the time the menu is displayed). 197 | - Fix the issue where the key is selected if `triggerKey` is called subsequent to `show` command with vim binding by having `show` command to wait until the QuickPick's show is called. 198 | 199 | ## [0.8.1] - 2020-09-02 200 | 201 | ### Fixed 202 | 203 | - Fix the issue where multiple status bar messages are displayed. 204 | 205 | ## [0.8.0] - 2020-09-02 206 | 207 | ### Added 208 | 209 | - Add color on the status bar message when key binding entered is not defined. 210 | - Add support for a new conditional type binding, which allows conditional binding execution. See README for more information on how to use it. 211 | 212 | ## [0.7.6] - 2020-08-03 213 | 214 | ### Added 215 | 216 | - Add an option to sort non-number first for keys of the menu items. 217 | 218 | ### Changed 219 | 220 | - Change ` b Y` to deselect after copying. 221 | 222 | ## [0.7.5] - 2020-08-01 223 | 224 | ### Added 225 | 226 | - Add a configuration (`whichkey.sortOrder`) to sort menu items. 227 | 228 | ## [0.7.4] - 2020-07-27 229 | 230 | ### Changed 231 | 232 | - Change the command `whichkey.show` to be non-blocking. 233 | 234 | ### Fixed 235 | 236 | - Fix a bug where only the first occurrence of ␣ and ↹ will be replaced. 237 | 238 | ## [0.7.3] - 2020-07-23 239 | 240 | ### Added 241 | 242 | - Add ` s r` to search reference. 243 | - Add ` s R` to search reference in side bar. 244 | - Add ` s J` to jump symbol in the workspace. 245 | 246 | ### Changed 247 | 248 | - Change ` s j` to jump to symbol in file. 249 | 250 | ## [0.7.2] - 2020-07-20 251 | 252 | ### Fixed 253 | 254 | - Fix typo and grammar in default binding names. 255 | 256 | ## [0.7.1] - 2020-07-19 257 | 258 | ### Security 259 | 260 | - Update lodash for GHSA-p6mc-m468-83gw. 261 | 262 | ## [0.7.0] - 2020-07-09 263 | 264 | ### Added 265 | 266 | - Add ` b H/J/K/L` for directional editor moving. 267 | - Support running this extension locally with VSCode Remote. 268 | 269 | ## [0.6.0] - 2020-07-09 270 | 271 | ### Added 272 | 273 | - Implement an a way to use non-character key like `` and `` in which-key menu. 274 | - Implement a way to delay menu display with a configurable timeout in settings. 275 | - Add better error message when executing binding with incorrect properties. 276 | - Add ` ` to switch to last editor. 277 | 278 | ## [0.5.3] - 2020-07-02 279 | 280 | ### Fixed 281 | 282 | - Fix an issue where the which key menu will not reopen once the transient menu is closed. 283 | 284 | ## [0.5.2] - 2020-06-30 285 | 286 | ### Fixed 287 | 288 | - Fix the issue where menu is empty when called from vscode vim. 289 | 290 | ## [0.5.1] - 2020-06-28 291 | 292 | ### Added 293 | 294 | - Use webpack to reduce extension size. 295 | 296 | ## [0.5.0] - 2020-06-23 297 | 298 | ### Added 299 | 300 | - Split which-key menu function from VSpaceCode of `v0.4.0`. 301 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | Thank you for taking the time to contributing to vscode-which-key. 4 | 5 | ## First Time Setup 6 | 7 | 1. Install prerequisites: 8 | - [Visual Studio Code](https://code.visualstudio.com/) 9 | - [Node.js](https://nodejs.org/) 10 | 2. Fork the repository 11 | 3. In a terminal 12 | 13 | ```sh 14 | # fork and clone the repository 15 | git clone git@github.com:/vscode-which-key.git 16 | cd vscode-which-key 17 | 18 | # Install the dependencies 19 | npm install 20 | 21 | # Open in VSCode 22 | code . 23 | ``` 24 | 25 | 4. Install [TypeScript + Webpack Problem Matchers for VS Code](https://marketplace.visualstudio.com/items?itemName=eamodio.tsl-problem-matcher) 26 | 5. Go to debug tab select `Run Extension` 27 | 28 | ## Default binding menu 29 | 30 | The default bindings of `which-key` are separate from the `VSpaceCode` 31 | bindings. To see the bindings from the `package.json` in this repository, run 32 | "Which Key: Show Menu" from the command palette (`Ctrl-Alt-P`, or `SPC SPC` 33 | with `VSpaceCode`). 34 | 35 | ## Submitting Issues 36 | 37 | Feel free to open an issue if the issue you are experiencing is not in already in the [Github issues](https://github.com/VSpaceCode/vscode-which-key/issues). 38 | 39 | ## Submitting Pull Requests 40 | 41 | If you are submitting an pull request (PR) without a tracking issue, consider create an issue first. This is so that we can discuss different implementations if necessary. 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Steven Guh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vscode-which-key (Preview) 2 | 3 | [![Docs](https://img.shields.io/website?label=vspacecode.github.io&url=https%3A%2F%2Fvspacecode.github.io)](https://vspacecode.github.io/docs/whichkey) 4 | [![Version](https://img.shields.io/visual-studio-marketplace/v/vspacecode.whichkey)](https://marketplace.visualstudio.com/items?itemName=vspacecode.whichkey) 5 | [![Installs](https://img.shields.io/visual-studio-marketplace/i/vspacecode.whichkey)](https://marketplace.visualstudio.com/items?itemName=vspacecode.whichkey) 6 | [![Ratings](https://img.shields.io/visual-studio-marketplace/r/vspacecode.whichkey)](https://marketplace.visualstudio.com/items?itemName=vspacecode.whichkey) 7 | 8 | This extension is aimed to provide the standalone which-key function in VScode for both users and extension to bundle. 9 | 10 | ## Features 11 | 12 | - All menu items are customizable 13 | - The menu key is customizable 14 | - Extension can bundle this to provide which-key menu 15 | 16 | ## Documentation 17 | 18 | See [here](https://vspacecode.github.io/docs/whichkey/). 19 | 20 | ## Release Notes 21 | 22 | See [CHANGELOG.md](CHANGELOG.md) 23 | 24 | ## [Contribution](CONTRIBUTING.md) 25 | 26 | All feature requests and help are welcome. Please open an issue to track. 27 | 28 | ## Credits 29 | 30 | Thanks @kahole for his implementation of quick pick menu in edamagit. 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whichkey", 3 | "preview": true, 4 | "displayName": "Which Key", 5 | "description": "which-key like menu for Visual Studio Code", 6 | "publisher": "VSpaceCode", 7 | "author": { 8 | "name": "Steven Guh" 9 | }, 10 | "version": "0.11.4", 11 | "engines": { 12 | "vscode": "^1.45.0" 13 | }, 14 | "galleryBanner": { 15 | "color": "#3a3d41", 16 | "theme": "dark" 17 | }, 18 | "categories": [ 19 | "Keymaps", 20 | "Other" 21 | ], 22 | "keywords": [ 23 | "which-key" 24 | ], 25 | "extensionKind": [ 26 | "ui", 27 | "workspace" 28 | ], 29 | "license": "SEE LICENSE IN LICENSE.txt", 30 | "homepage": "https://github.com/VSpaceCode/vscode-which-key", 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/VSpaceCode/vscode-which-key.git" 34 | }, 35 | "bugs": { 36 | "url": "https://github.com/VSpaceCode/vscode-which-key/issues" 37 | }, 38 | "activationEvents": [ 39 | "onCommand:whichkey.show", 40 | "onCommand:whichkey.showTransient", 41 | "onCommand:whichkey.register" 42 | ], 43 | "main": "./dist/extension-node", 44 | "browser": "./dist/extension-web", 45 | "contributes": { 46 | "keybindings": [ 47 | { 48 | "key": "tab", 49 | "command": "whichkey.triggerKey", 50 | "args": "\t", 51 | "when": "whichkeyVisible" 52 | }, 53 | { 54 | "key": "ctrl+h", 55 | "command": "whichkey.searchBindings", 56 | "when": "whichkeyVisible" 57 | } 58 | ], 59 | "commands": [ 60 | { 61 | "command": "whichkey.show", 62 | "title": "Show Menu", 63 | "category": "Which Key" 64 | } 65 | ], 66 | "configuration": [ 67 | { 68 | "title": "Which Key", 69 | "type": "object", 70 | "properties": { 71 | "whichkey.transient": { 72 | "type": "object", 73 | "default": { 74 | "error": { 75 | "title": "Error transient", 76 | "bindings": [ 77 | { 78 | "key": "N", 79 | "name": "Previous error", 80 | "command": "editor.action.marker.prev" 81 | }, 82 | { 83 | "key": "n", 84 | "name": "Next error", 85 | "command": "editor.action.marker.next" 86 | }, 87 | { 88 | "key": "p", 89 | "name": "Previous error", 90 | "command": "editor.action.marker.prev" 91 | } 92 | ] 93 | }, 94 | "symbol": { 95 | "title": "Highlight symbol transient", 96 | "bindings": [ 97 | { 98 | "key": "p", 99 | "name": "Previous occurrence", 100 | "command": "editor.action.wordHighlight.prev" 101 | }, 102 | { 103 | "key": "N", 104 | "name": "Previous occurrence", 105 | "command": "editor.action.wordHighlight.prev" 106 | }, 107 | { 108 | "key": "n", 109 | "name": "Next occurrence", 110 | "command": "editor.action.wordHighlight.next" 111 | }, 112 | { 113 | "key": "/", 114 | "name": "Search in a project with a selection", 115 | "commands": [ 116 | "editor.action.addSelectionToNextFindMatch", 117 | "workbench.action.findInFiles" 118 | ] 119 | } 120 | ] 121 | }, 122 | "lineMoving": { 123 | "title": "Line moving transient", 124 | "bindings": [ 125 | { 126 | "key": "J", 127 | "name": "Move lines down", 128 | "command": "editor.action.moveLinesDownAction" 129 | }, 130 | { 131 | "key": "K", 132 | "name": "Move lines up", 133 | "command": "editor.action.moveLinesUpAction" 134 | } 135 | ] 136 | }, 137 | "frameZooming": { 138 | "title": "Frame zooming transient", 139 | "bindings": [ 140 | { 141 | "key": "=", 142 | "name": "Zoom in", 143 | "command": "workbench.action.zoomIn" 144 | }, 145 | { 146 | "key": "+", 147 | "name": "Zoom in", 148 | "command": "workbench.action.zoomIn" 149 | }, 150 | { 151 | "key": "-", 152 | "name": "Zoom out", 153 | "command": "workbench.action.zoomOut" 154 | }, 155 | { 156 | "key": "0", 157 | "name": "Reset zoom", 158 | "command": "workbench.action.zoomReset" 159 | } 160 | ] 161 | }, 162 | "fontZooming": { 163 | "title": "Front zooming transient", 164 | "bindings": [ 165 | { 166 | "key": "=", 167 | "name": "Zoom in", 168 | "command": "editor.action.fontZoomIn" 169 | }, 170 | { 171 | "key": "+", 172 | "name": "Zoom in", 173 | "command": "editor.action.fontZoomIn" 174 | }, 175 | { 176 | "key": "-", 177 | "name": "Zoom out", 178 | "command": "editor.action.fontZoomOut" 179 | }, 180 | { 181 | "key": "0", 182 | "name": "Reset zoom", 183 | "command": "editor.action.fontZoomReset" 184 | } 185 | ] 186 | }, 187 | "imageZooming": { 188 | "title": "Image zooming transient", 189 | "bindings": [ 190 | { 191 | "key": "=", 192 | "name": "Zoom in", 193 | "command": "imagePreview.zoomIn" 194 | }, 195 | { 196 | "key": "+", 197 | "name": "Zoom in", 198 | "command": "imagePreview.zoomIn" 199 | }, 200 | { 201 | "key": "-", 202 | "name": "Zoom out", 203 | "command": "imagePreview.zoomOut" 204 | } 205 | ] 206 | }, 207 | "smartExpand": { 208 | "title": "Smart expand transient", 209 | "bindings": [ 210 | { 211 | "key": "v", 212 | "name": "Grow selection", 213 | "command": "editor.action.smartSelect.grow" 214 | }, 215 | { 216 | "key": "V", 217 | "name": "Shrink selection", 218 | "command": "editor.action.smartSelect.shrink" 219 | } 220 | ] 221 | } 222 | } 223 | }, 224 | "whichkey.delay": { 225 | "type": "number", 226 | "markdownDescription": "Delay (in milliseconds) for which-key menu to display.", 227 | "default": 0 228 | }, 229 | "whichkey.showIcons": { 230 | "type": "boolean", 231 | "markdownDescription": "Controls whether to show or hide icons in the which-key menu.", 232 | "default": true 233 | }, 234 | "whichkey.showButtons": { 235 | "type": "boolean", 236 | "markdownDescription": "Controls whether to show or hide buttons in the which-key menu.", 237 | "default": true 238 | }, 239 | "whichkey.useFullWidthCharacters": { 240 | "type": "boolean", 241 | "markdownDescription": "Controls whether to use full width characters as key in the which-key menu.", 242 | "default": false 243 | }, 244 | "whichkey.sortOrder": { 245 | "type": "string", 246 | "default": "none", 247 | "enum": [ 248 | "none", 249 | "custom", 250 | "customNonNumberFirst", 251 | "typeThenCustom", 252 | "alphabetically", 253 | "nonNumberFirst" 254 | ], 255 | "markdownEnumDescriptions": [ 256 | "Menu items are not sorted.", 257 | "Menu items are sorted by the key in the following 'categories' then by a custom order within each 'category'.\nThe category order:\n1. Single key (a, z, SPC, TAB, etc)\n2. Function key (f11, F11, etc)\n3. Modifier key (C-z, etc)\n4. Others\n\n For the non-function key, the sort order of each character of the key:\n1. SPC\n2. Non-printable characters\n3. DEL\n4. ASCII symbols\n5. Number\n6. a-z\n7. A-Z\n8. Non-ASCII\n\n For function key, bindings will be sorted by the numeric order (e.g. F1, F2, F11, 12).", 258 | "Menu items are sorted by bindings with non-number key first then by custom order.", 259 | "Menu items are sorted by the binding type first then by custom order.", 260 | "Menu items are sorted by the key in alphabetical order.", 261 | "Menu items are sorted by bindings with non-number key first then by alphabetical order." 262 | ], 263 | "markdownDescription": "Controls the sorting order of the which-key menu items." 264 | }, 265 | "whichkey.bindings": { 266 | "type": "array", 267 | "markdownDescription": "The bindings of the which key menu", 268 | "default": [ 269 | { 270 | "key": " ", 271 | "name": "Commands", 272 | "type": "command", 273 | "command": "workbench.action.showCommands" 274 | }, 275 | { 276 | "key": "\t", 277 | "name": "Last editor", 278 | "type": "commands", 279 | "commands": [ 280 | "workbench.action.quickOpenPreviousRecentlyUsedEditorInGroup", 281 | "list.select" 282 | ] 283 | }, 284 | { 285 | "key": "?", 286 | "name": "Search keybindings", 287 | "type": "command", 288 | "command": "whichkey.searchBindings" 289 | }, 290 | { 291 | "key": ".", 292 | "name": "Repeat most recent action", 293 | "type": "command", 294 | "command": "whichkey.repeatMostRecent" 295 | }, 296 | { 297 | "key": "b", 298 | "name": "+Buffers/Editors", 299 | "type": "bindings", 300 | "bindings": [ 301 | { 302 | "key": "b", 303 | "name": "Show all buffers/editors", 304 | "type": "command", 305 | "command": "workbench.action.showAllEditors" 306 | }, 307 | { 308 | "key": "B", 309 | "name": "Show all buffers/editors in active group", 310 | "type": "command", 311 | "command": "workbench.action.showEditorsInActiveGroup" 312 | }, 313 | { 314 | "key": "d", 315 | "name": "Close active editor", 316 | "type": "command", 317 | "command": "workbench.action.closeActiveEditor" 318 | }, 319 | { 320 | "key": "H", 321 | "name": "Move editor into left group", 322 | "type": "command", 323 | "command": "workbench.action.moveEditorToLeftGroup" 324 | }, 325 | { 326 | "key": "J", 327 | "name": "Move editor into below group", 328 | "type": "command", 329 | "command": "workbench.action.moveEditorToBelowGroup" 330 | }, 331 | { 332 | "key": "K", 333 | "name": "Move editor into above group", 334 | "type": "command", 335 | "command": "workbench.action.moveEditorToAboveGroup" 336 | }, 337 | { 338 | "key": "L", 339 | "name": "Move editor into right group", 340 | "type": "command", 341 | "command": "workbench.action.moveEditorToRightGroup" 342 | }, 343 | { 344 | "key": "M", 345 | "name": "Close other editors", 346 | "type": "command", 347 | "command": "workbench.action.closeOtherEditors" 348 | }, 349 | { 350 | "key": "n", 351 | "name": "Next editor", 352 | "type": "command", 353 | "command": "workbench.action.nextEditor" 354 | }, 355 | { 356 | "key": "p", 357 | "name": "Previous editor", 358 | "type": "command", 359 | "command": "workbench.action.previousEditor" 360 | }, 361 | { 362 | "key": "N", 363 | "name": "New untitled editor", 364 | "type": "command", 365 | "command": "workbench.action.files.newUntitledFile" 366 | }, 367 | { 368 | "key": "u", 369 | "name": "Reopen closed editor", 370 | "type": "command", 371 | "command": "workbench.action.reopenClosedEditor" 372 | }, 373 | { 374 | "key": "P", 375 | "name": "Paste clipboard to buffer", 376 | "type": "commands", 377 | "commands": [ 378 | "editor.action.selectAll", 379 | "editor.action.clipboardPasteAction" 380 | ] 381 | }, 382 | { 383 | "key": "Y", 384 | "name": "Copy buffer to clipboard", 385 | "type": "commands", 386 | "commands": [ 387 | "editor.action.selectAll", 388 | "editor.action.clipboardCopyAction", 389 | "cancelSelection" 390 | ] 391 | } 392 | ] 393 | }, 394 | { 395 | "key": "d", 396 | "name": "+Debug", 397 | "type": "bindings", 398 | "bindings": [ 399 | { 400 | "key": "d", 401 | "name": "Start debug", 402 | "type": "command", 403 | "command": "workbench.action.debug.start" 404 | }, 405 | { 406 | "key": "D", 407 | "name": "Run without debugging", 408 | "type": "command", 409 | "command": "workbench.action.debug.run" 410 | }, 411 | { 412 | "key": "S", 413 | "name": "Stop debug", 414 | "type": "command", 415 | "command": "workbench.action.debug.stop" 416 | }, 417 | { 418 | "key": "c", 419 | "name": "Continue debug", 420 | "type": "command", 421 | "command": "workbench.action.debug.continue" 422 | }, 423 | { 424 | "key": "p", 425 | "name": "Pause debug", 426 | "type": "command", 427 | "command": "workbench.action.debug.pause" 428 | }, 429 | { 430 | "key": "R", 431 | "name": "Restart debug", 432 | "type": "command", 433 | "command": "workbench.action.debug.restart" 434 | }, 435 | { 436 | "key": "i", 437 | "name": "Step into", 438 | "type": "command", 439 | "command": "workbench.action.debug.stepInto" 440 | }, 441 | { 442 | "key": "s", 443 | "name": "Step over", 444 | "type": "command", 445 | "command": "workbench.action.debug.stepOver" 446 | }, 447 | { 448 | "key": "o", 449 | "name": "Step out", 450 | "type": "command", 451 | "command": "workbench.action.debug.stepOut" 452 | }, 453 | { 454 | "key": "b", 455 | "name": "Toggle breakpoint", 456 | "type": "command", 457 | "command": "editor.debug.action.toggleBreakpoint" 458 | }, 459 | { 460 | "key": "B", 461 | "name": "Toggle inline breakpoint", 462 | "type": "command", 463 | "command": "editor.debug.action.toggleInlineBreakpoint" 464 | }, 465 | { 466 | "key": "j", 467 | "name": "Jump to cursor", 468 | "type": "command", 469 | "command": "debug.jumpToCursor" 470 | }, 471 | { 472 | "key": "v", 473 | "name": "REPL", 474 | "type": "command", 475 | "command": "workbench.debug.action.toggleRepl" 476 | }, 477 | { 478 | "key": "w", 479 | "name": "Focus on watch window", 480 | "type": "command", 481 | "command": "workbench.debug.action.focusWatchView" 482 | }, 483 | { 484 | "key": "W", 485 | "name": "Add to watch", 486 | "type": "command", 487 | "command": "editor.debug.action.selectionToWatch" 488 | } 489 | ] 490 | }, 491 | { 492 | "key": "e", 493 | "name": "+Errors", 494 | "type": "bindings", 495 | "bindings": [ 496 | { 497 | "key": ".", 498 | "name": "Error transient", 499 | "type": "command", 500 | "command": "whichkey.showTransient", 501 | "args": "whichkey.transient.error" 502 | }, 503 | { 504 | "key": "l", 505 | "name": "List errors", 506 | "type": "command", 507 | "command": "workbench.actions.view.problems" 508 | }, 509 | { 510 | "key": "N", 511 | "name": "Previous error", 512 | "type": "command", 513 | "command": "editor.action.marker.prev" 514 | }, 515 | { 516 | "key": "n", 517 | "name": "Next error", 518 | "type": "command", 519 | "command": "editor.action.marker.next" 520 | }, 521 | { 522 | "key": "p", 523 | "name": "Previous error", 524 | "type": "command", 525 | "command": "editor.action.marker.prev" 526 | } 527 | ] 528 | }, 529 | { 530 | "key": "f", 531 | "name": "+File", 532 | "type": "bindings", 533 | "bindings": [ 534 | { 535 | "key": "f", 536 | "name": "Open file/folder", 537 | "type": "command", 538 | "command": "whichkey.openFile" 539 | }, 540 | { 541 | "key": "n", 542 | "name": "New Untitled", 543 | "type": "command", 544 | "command": "workbench.action.files.newUntitledFile" 545 | }, 546 | { 547 | "key": "w", 548 | "name": "Open active in new window", 549 | "type": "command", 550 | "command": "workbench.action.files.showOpenedFileInNewWindow" 551 | }, 552 | { 553 | "key": "s", 554 | "name": "Save file", 555 | "type": "command", 556 | "command": "workbench.action.files.save" 557 | }, 558 | { 559 | "key": "S", 560 | "name": "Save all files", 561 | "type": "command", 562 | "command": "workbench.action.files.saveAll" 563 | }, 564 | { 565 | "key": "r", 566 | "name": "Open recent", 567 | "type": "command", 568 | "command": "workbench.action.openRecent" 569 | }, 570 | { 571 | "key": "R", 572 | "name": "Rename file", 573 | "type": "commands", 574 | "commands": [ 575 | "workbench.files.action.showActiveFileInExplorer", 576 | "renameFile" 577 | ] 578 | }, 579 | { 580 | "key": "t", 581 | "name": "Show tree/explorer view", 582 | "type": "command", 583 | "command": "workbench.view.explorer" 584 | }, 585 | { 586 | "key": "T", 587 | "name": "Show active file in tree/explorer view", 588 | "type": "command", 589 | "command": "workbench.files.action.showActiveFileInExplorer" 590 | }, 591 | { 592 | "key": "y", 593 | "name": "Copy path of active file", 594 | "type": "command", 595 | "command": "workbench.action.files.copyPathOfActiveFile" 596 | }, 597 | { 598 | "key": "o", 599 | "name": "Open with", 600 | "type": "command", 601 | "command": "explorer.openWith" 602 | }, 603 | { 604 | "key": "l", 605 | "name": "Change file language", 606 | "type": "command", 607 | "command": "workbench.action.editor.changeLanguageMode" 608 | }, 609 | { 610 | "key": "=", 611 | "name": "Format file", 612 | "type": "command", 613 | "command": "editor.action.formatDocument" 614 | }, 615 | { 616 | "key": "i", 617 | "name": "+Indentation", 618 | "type": "bindings", 619 | "bindings": [ 620 | { 621 | "key": "i", 622 | "name": "Change indentation", 623 | "type": "command", 624 | "command": "changeEditorIndentation" 625 | }, 626 | { 627 | "key": "d", 628 | "name": "Detect indentation", 629 | "type": "command", 630 | "command": "editor.action.detectIndentation" 631 | }, 632 | { 633 | "key": "r", 634 | "name": "Reindent", 635 | "type": "command", 636 | "command": "editor.action.reindentlines" 637 | }, 638 | { 639 | "key": "R", 640 | "name": "Reindent selected", 641 | "type": "command", 642 | "command": "editor.action.reindentselectedlines" 643 | }, 644 | { 645 | "key": "t", 646 | "name": "Convert indentation to tabs", 647 | "type": "command", 648 | "command": "editor.action.indentationToTabs" 649 | }, 650 | { 651 | "key": "s", 652 | "name": "Convert indentation to spaces", 653 | "type": "command", 654 | "command": "editor.action.indentationToSpaces" 655 | } 656 | ] 657 | } 658 | ] 659 | }, 660 | { 661 | "key": "g", 662 | "name": "+Git", 663 | "type": "bindings", 664 | "bindings": [ 665 | { 666 | "key": "b", 667 | "name": "Checkout", 668 | "type": "command", 669 | "command": "git.checkout" 670 | }, 671 | { 672 | "key": "c", 673 | "name": "Commit", 674 | "type": "command", 675 | "command": "git.commit" 676 | }, 677 | { 678 | "key": "d", 679 | "name": "Delete Branch", 680 | "type": "command", 681 | "command": "git.deleteBranch" 682 | }, 683 | { 684 | "key": "f", 685 | "name": "Fetch", 686 | "type": "command", 687 | "command": "git.fetch" 688 | }, 689 | { 690 | "key": "i", 691 | "name": "Init", 692 | "type": "command", 693 | "command": "git.init" 694 | }, 695 | { 696 | "key": "m", 697 | "name": "Merge", 698 | "type": "command", 699 | "command": "git.merge" 700 | }, 701 | { 702 | "key": "p", 703 | "name": "Publish", 704 | "type": "command", 705 | "command": "git.publish" 706 | }, 707 | { 708 | "key": "s", 709 | "name": "Status", 710 | "type": "command", 711 | "command": "workbench.view.scm" 712 | }, 713 | { 714 | "key": "S", 715 | "name": "Stage", 716 | "type": "command", 717 | "command": "git.stage" 718 | }, 719 | { 720 | "key": "U", 721 | "name": "Unstage", 722 | "type": "command", 723 | "command": "git.unstage" 724 | } 725 | ] 726 | }, 727 | { 728 | "key": "i", 729 | "name": "+Insert", 730 | "type": "bindings", 731 | "bindings": [ 732 | { 733 | "key": "j", 734 | "name": "Insert line below", 735 | "type": "command", 736 | "command": "editor.action.insertLineAfter" 737 | }, 738 | { 739 | "key": "k", 740 | "name": "Insert line above", 741 | "type": "command", 742 | "command": "editor.action.insertLineBefore" 743 | }, 744 | { 745 | "key": "s", 746 | "name": "Insert snippet", 747 | "type": "command", 748 | "command": "editor.action.insertSnippet" 749 | } 750 | ] 751 | }, 752 | { 753 | "key": "p", 754 | "name": "+Project", 755 | "type": "bindings", 756 | "bindings": [ 757 | { 758 | "key": "f", 759 | "name": "Find file in project", 760 | "type": "command", 761 | "command": "workbench.action.quickOpen" 762 | }, 763 | { 764 | "key": "p", 765 | "name": "Switch project", 766 | "type": "command", 767 | "command": "workbench.action.openRecent" 768 | }, 769 | { 770 | "key": "t", 771 | "name": "Show tree/explorer view", 772 | "type": "command", 773 | "command": "workbench.view.explorer" 774 | } 775 | ] 776 | }, 777 | { 778 | "key": "r", 779 | "name": "+Repeat", 780 | "type": "bindings", 781 | "bindings": [ 782 | { 783 | "key": ".", 784 | "name": "Repeat recent actions", 785 | "type": "command", 786 | "command": "whichkey.repeatRecent" 787 | } 788 | ] 789 | }, 790 | { 791 | "key": "s", 792 | "name": "+Search/Symbol", 793 | "type": "bindings", 794 | "bindings": [ 795 | { 796 | "key": "e", 797 | "name": "Edit symbol", 798 | "type": "command", 799 | "command": "editor.action.rename" 800 | }, 801 | { 802 | "key": "h", 803 | "name": "Highlight symbol transient", 804 | "type": "commands", 805 | "commands": [ 806 | "editor.action.wordHighlight.trigger", 807 | "whichkey.showTransient" 808 | ], 809 | "args": [ 810 | null, 811 | "whichkey.transient.symbol" 812 | ] 813 | }, 814 | { 815 | "key": "j", 816 | "name": "Jump to symbol in file", 817 | "type": "command", 818 | "command": "workbench.action.gotoSymbol" 819 | }, 820 | { 821 | "key": "J", 822 | "name": "Jump to symbol in workspace", 823 | "type": "command", 824 | "command": "workbench.action.showAllSymbols" 825 | }, 826 | { 827 | "key": "p", 828 | "name": "Search in a project", 829 | "type": "command", 830 | "command": "workbench.action.findInFiles" 831 | }, 832 | { 833 | "key": "P", 834 | "name": "Search in a project with a selection", 835 | "type": "commands", 836 | "commands": [ 837 | "editor.action.addSelectionToNextFindMatch", 838 | "workbench.action.findInFiles" 839 | ] 840 | }, 841 | { 842 | "key": "r", 843 | "name": "Search all references", 844 | "type": "command", 845 | "command": "editor.action.referenceSearch.trigger" 846 | }, 847 | { 848 | "key": "R", 849 | "name": "Search all references in side bar", 850 | "type": "command", 851 | "command": "references-view.find" 852 | }, 853 | { 854 | "key": "s", 855 | "name": "Search in current file", 856 | "type": "command", 857 | "command": "actions.find" 858 | } 859 | ] 860 | }, 861 | { 862 | "key": "S", 863 | "name": "+Show", 864 | "type": "bindings", 865 | "bindings": [ 866 | { 867 | "key": "e", 868 | "name": "Show explorer", 869 | "type": "command", 870 | "command": "workbench.view.explorer" 871 | }, 872 | { 873 | "key": "s", 874 | "name": "Show search", 875 | "type": "command", 876 | "command": "workbench.view.search" 877 | }, 878 | { 879 | "key": "g", 880 | "name": "Show source control", 881 | "type": "command", 882 | "command": "workbench.view.scm" 883 | }, 884 | { 885 | "key": "t", 886 | "name": "Show test", 887 | "type": "command", 888 | "command": "workbench.view.extension.test" 889 | }, 890 | { 891 | "key": "r", 892 | "name": "Show remote explorer", 893 | "type": "command", 894 | "command": "workbench.view.remote" 895 | }, 896 | { 897 | "key": "x", 898 | "name": "Show extensions", 899 | "type": "command", 900 | "command": "workbench.view.extensions" 901 | }, 902 | { 903 | "key": "p", 904 | "name": "Show problem", 905 | "type": "command", 906 | "command": "workbench.actions.view.problems" 907 | }, 908 | { 909 | "key": "o", 910 | "name": "Show output", 911 | "type": "command", 912 | "command": "workbench.action.output.toggleOutput" 913 | }, 914 | { 915 | "key": "d", 916 | "name": "Show debug console", 917 | "type": "command", 918 | "command": "workbench.debug.action.toggleRepl" 919 | } 920 | ] 921 | }, 922 | { 923 | "key": "t", 924 | "name": "+Toggles", 925 | "type": "bindings", 926 | "bindings": [ 927 | { 928 | "key": "c", 929 | "name": "Toggle find case sensitive", 930 | "type": "command", 931 | "command": "toggleFindCaseSensitive" 932 | }, 933 | { 934 | "key": "w", 935 | "name": "Toggle ignore trim whitespace in diff", 936 | "type": "command", 937 | "command": "toggle.diff.ignoreTrimWhitespace" 938 | }, 939 | { 940 | "key": "W", 941 | "name": "Toggle word wrap", 942 | "type": "command", 943 | "command": "editor.action.toggleWordWrap" 944 | } 945 | ] 946 | }, 947 | { 948 | "key": "T", 949 | "name": "+UI toggles", 950 | "type": "bindings", 951 | "bindings": [ 952 | { 953 | "key": "b", 954 | "name": "Toggle side bar visibility", 955 | "type": "command", 956 | "command": "workbench.action.toggleSidebarVisibility" 957 | }, 958 | { 959 | "key": "j", 960 | "name": "Toggle panel visibility", 961 | "type": "command", 962 | "command": "workbench.action.togglePanel" 963 | }, 964 | { 965 | "key": "F", 966 | "name": "Toggle full screen", 967 | "type": "command", 968 | "command": "workbench.action.toggleFullScreen" 969 | }, 970 | { 971 | "key": "s", 972 | "name": "Select theme", 973 | "type": "command", 974 | "command": "workbench.action.selectTheme" 975 | }, 976 | { 977 | "key": "m", 978 | "name": "Toggle maximized panel", 979 | "type": "command", 980 | "command": "workbench.action.toggleMaximizedPanel" 981 | }, 982 | { 983 | "key": "t", 984 | "name": "Toggle tool/activity bar visibility", 985 | "type": "command", 986 | "command": "workbench.action.toggleActivityBarVisibility" 987 | }, 988 | { 989 | "key": "T", 990 | "name": "Toggle tab visibility", 991 | "type": "command", 992 | "command": "workbench.action.toggleTabsVisibility" 993 | }, 994 | { 995 | "key": "z", 996 | "name": "Toggle zen mode", 997 | "type": "command", 998 | "command": "workbench.action.toggleZenMode" 999 | } 1000 | ] 1001 | }, 1002 | { 1003 | "key": "w", 1004 | "name": "+Window", 1005 | "type": "bindings", 1006 | "bindings": [ 1007 | { 1008 | "key": "W", 1009 | "name": "Focus previous editor group", 1010 | "type": "command", 1011 | "command": "workbench.action.focusPreviousGroup" 1012 | }, 1013 | { 1014 | "key": "-", 1015 | "name": "Split editor below", 1016 | "type": "command", 1017 | "command": "workbench.action.splitEditorDown" 1018 | }, 1019 | { 1020 | "key": "/", 1021 | "name": "Split editor right", 1022 | "type": "command", 1023 | "command": "workbench.action.splitEditor" 1024 | }, 1025 | { 1026 | "key": "s", 1027 | "name": "Split editor below", 1028 | "type": "command", 1029 | "command": "workbench.action.splitEditorDown" 1030 | }, 1031 | { 1032 | "key": "v", 1033 | "name": "Split editor right", 1034 | "type": "command", 1035 | "command": "workbench.action.splitEditor" 1036 | }, 1037 | { 1038 | "key": "h", 1039 | "name": "Focus editor group left", 1040 | "type": "command", 1041 | "command": "workbench.action.focusLeftGroup" 1042 | }, 1043 | { 1044 | "key": "j", 1045 | "name": "Focus editor group down", 1046 | "type": "command", 1047 | "command": "workbench.action.focusBelowGroup" 1048 | }, 1049 | { 1050 | "key": "k", 1051 | "name": "Focus editor group up", 1052 | "type": "command", 1053 | "command": "workbench.action.focusAboveGroup" 1054 | }, 1055 | { 1056 | "key": "l", 1057 | "name": "Focus editor group right", 1058 | "type": "command", 1059 | "command": "workbench.action.focusRightGroup" 1060 | }, 1061 | { 1062 | "key": "H", 1063 | "name": "Move editor group left", 1064 | "type": "command", 1065 | "command": "workbench.action.moveActiveEditorGroupLeft" 1066 | }, 1067 | { 1068 | "key": "J", 1069 | "name": "Move editor group down", 1070 | "type": "command", 1071 | "command": "workbench.action.moveActiveEditorGroupDown" 1072 | }, 1073 | { 1074 | "key": "K", 1075 | "name": "Move editor group up", 1076 | "type": "command", 1077 | "command": "workbench.action.moveActiveEditorGroupUp" 1078 | }, 1079 | { 1080 | "key": "L", 1081 | "name": "Move editor group right", 1082 | "type": "command", 1083 | "command": "workbench.action.moveActiveEditorGroupRight" 1084 | }, 1085 | { 1086 | "key": "t", 1087 | "name": "Toggle editor group sizes", 1088 | "type": "command", 1089 | "command": "workbench.action.toggleEditorWidths" 1090 | }, 1091 | { 1092 | "key": "m", 1093 | "name": "Maximize editor group", 1094 | "type": "command", 1095 | "command": "workbench.action.minimizeOtherEditors" 1096 | }, 1097 | { 1098 | "key": "M", 1099 | "name": "Maximize editor group and hide side bar", 1100 | "type": "command", 1101 | "command": "workbench.action.maximizeEditor" 1102 | }, 1103 | { 1104 | "key": "=", 1105 | "name": "Reset editor group sizes", 1106 | "type": "command", 1107 | "command": "workbench.action.evenEditorWidths" 1108 | }, 1109 | { 1110 | "key": "z", 1111 | "name": "Combine all editors", 1112 | "type": "command", 1113 | "command": "workbench.action.joinAllGroups" 1114 | }, 1115 | { 1116 | "key": "d", 1117 | "name": "Close editor group", 1118 | "type": "command", 1119 | "command": "workbench.action.closeEditorsInGroup" 1120 | }, 1121 | { 1122 | "key": "x", 1123 | "name": "Close all editor groups", 1124 | "type": "command", 1125 | "command": "workbench.action.closeAllGroups" 1126 | } 1127 | ] 1128 | }, 1129 | { 1130 | "key": "x", 1131 | "name": "+Text", 1132 | "type": "bindings", 1133 | "bindings": [ 1134 | { 1135 | "key": "i", 1136 | "name": "Organize Imports", 1137 | "type": "command", 1138 | "command": "editor.action.organizeImports" 1139 | }, 1140 | { 1141 | "key": "r", 1142 | "name": "Rename symbol", 1143 | "type": "command", 1144 | "command": "editor.action.rename" 1145 | }, 1146 | { 1147 | "key": "R", 1148 | "name": "Refactor", 1149 | "type": "command", 1150 | "command": "editor.action.refactor" 1151 | }, 1152 | { 1153 | "key": ".", 1154 | "name": "Quick fix", 1155 | "type": "command", 1156 | "command": "editor.action.quickFix" 1157 | }, 1158 | { 1159 | "key": "a", 1160 | "name": "Find all references", 1161 | "type": "command", 1162 | "command": "editor.action.referenceSearch.trigger" 1163 | }, 1164 | { 1165 | "key": "u", 1166 | "name": "To lower case", 1167 | "type": "command", 1168 | "command": "editor.action.transformToLowercase" 1169 | }, 1170 | { 1171 | "key": "U", 1172 | "name": "To upper case", 1173 | "type": "command", 1174 | "command": "editor.action.transformToUppercase" 1175 | }, 1176 | { 1177 | "key": "J", 1178 | "name": "Move lines down transient", 1179 | "type": "commands", 1180 | "commands": [ 1181 | "editor.action.moveLinesDownAction", 1182 | "whichkey.showTransient" 1183 | ], 1184 | "args": [ 1185 | null, 1186 | "whichkey.transient.lineMoving" 1187 | ] 1188 | }, 1189 | { 1190 | "key": "K", 1191 | "name": "Move lines up transient", 1192 | "type": "commands", 1193 | "commands": [ 1194 | "editor.action.moveLinesUpAction", 1195 | "whichkey.showTransient" 1196 | ], 1197 | "args": [ 1198 | null, 1199 | "whichkey.transient.lineMoving" 1200 | ] 1201 | }, 1202 | { 1203 | "key": "l", 1204 | "name": "+Lines", 1205 | "type": "bindings", 1206 | "bindings": [ 1207 | { 1208 | "key": "s", 1209 | "name": "Sort lines in ascending order", 1210 | "type": "command", 1211 | "command": "editor.action.sortLinesAscending" 1212 | }, 1213 | { 1214 | "key": "S", 1215 | "name": "Sort lines in descending order", 1216 | "type": "command", 1217 | "command": "editor.action.sortLinesDescending" 1218 | }, 1219 | { 1220 | "key": "d", 1221 | "name": "Duplicate lines down", 1222 | "type": "command", 1223 | "command": "editor.action.copyLinesDownAction" 1224 | }, 1225 | { 1226 | "key": "D", 1227 | "name": "Duplicate lines up", 1228 | "type": "command", 1229 | "command": "editor.action.copyLinesUpAction" 1230 | } 1231 | ] 1232 | }, 1233 | { 1234 | "key": "d", 1235 | "name": "+Delete", 1236 | "type": "bindings", 1237 | "bindings": [ 1238 | { 1239 | "key": "w", 1240 | "name": "Delete trailing whitespace", 1241 | "type": "command", 1242 | "command": "editor.action.trimTrailingWhitespace" 1243 | } 1244 | ] 1245 | }, 1246 | { 1247 | "key": "m", 1248 | "name": "+Merge conflict", 1249 | "type": "bindings", 1250 | "bindings": [ 1251 | { 1252 | "key": "b", 1253 | "name": "Accept both", 1254 | "type": "command", 1255 | "command": "merge-conflict.accept.both" 1256 | }, 1257 | { 1258 | "key": "c", 1259 | "name": "Accept current", 1260 | "type": "command", 1261 | "command": "merge-conflict.accept.current" 1262 | }, 1263 | { 1264 | "key": "i", 1265 | "name": "Accept incoming", 1266 | "type": "command", 1267 | "command": "merge-conflict.accept.incoming" 1268 | }, 1269 | { 1270 | "key": "B", 1271 | "name": "Accept all both", 1272 | "type": "command", 1273 | "command": "merge-conflict.accept.all-both" 1274 | }, 1275 | { 1276 | "key": "C", 1277 | "name": "Accept all current", 1278 | "type": "command", 1279 | "command": "merge-conflict.accept.all-current" 1280 | }, 1281 | { 1282 | "key": "I", 1283 | "name": "Accept all incoming", 1284 | "type": "command", 1285 | "command": "merge-conflict.accept.all-incoming" 1286 | }, 1287 | { 1288 | "key": "s", 1289 | "name": "Accept selection", 1290 | "type": "command", 1291 | "command": "merge-conflict.accept.selection" 1292 | }, 1293 | { 1294 | "key": "k", 1295 | "name": "Compare current conflict", 1296 | "type": "command", 1297 | "command": "merge-conflict.compare" 1298 | }, 1299 | { 1300 | "key": "n", 1301 | "name": "Next Conflict", 1302 | "type": "command", 1303 | "command": "merge-conflict.next" 1304 | }, 1305 | { 1306 | "key": "N", 1307 | "name": "Previous Conflict", 1308 | "type": "command", 1309 | "command": "merge-conflict.previous" 1310 | } 1311 | ] 1312 | } 1313 | ] 1314 | }, 1315 | { 1316 | "key": "z", 1317 | "name": "+Zoom/Fold", 1318 | "type": "bindings", 1319 | "bindings": [ 1320 | { 1321 | "key": ".", 1322 | "name": "+Fold", 1323 | "type": "bindings", 1324 | "bindings": [ 1325 | { 1326 | "key": "a", 1327 | "name": "Toggle: around a point", 1328 | "type": "command", 1329 | "command": "editor.toggleFold" 1330 | }, 1331 | { 1332 | "key": "c", 1333 | "name": "Close: at a point", 1334 | "type": "command", 1335 | "command": "editor.fold" 1336 | }, 1337 | { 1338 | "key": "b", 1339 | "name": "Close: all block comments", 1340 | "type": "command", 1341 | "command": "editor.foldAllBlockComments" 1342 | }, 1343 | { 1344 | "key": "g", 1345 | "name": "Close: all regions", 1346 | "type": "command", 1347 | "command": "editor.foldAllMarkerRegions" 1348 | }, 1349 | { 1350 | "key": "m", 1351 | "name": "Close: all", 1352 | "type": "command", 1353 | "command": "editor.foldAll" 1354 | }, 1355 | { 1356 | "key": "o", 1357 | "name": "Open: at a point", 1358 | "type": "command", 1359 | "command": "editor.unfold" 1360 | }, 1361 | { 1362 | "key": "O", 1363 | "name": "Open: recursively", 1364 | "type": "command", 1365 | "command": "editor.unfoldRecursively" 1366 | }, 1367 | { 1368 | "key": "G", 1369 | "name": "Open: all regions", 1370 | "type": "command", 1371 | "command": "editor.unfoldAllMarkerRegions" 1372 | }, 1373 | { 1374 | "key": "r", 1375 | "name": "Open: all", 1376 | "type": "command", 1377 | "command": "editor.unfoldAll" 1378 | } 1379 | ] 1380 | }, 1381 | { 1382 | "key": "f", 1383 | "name": "Frame zooming transient", 1384 | "type": "command", 1385 | "command": "whichkey.showTransient", 1386 | "args": "whichkey.transient.frameZooming" 1387 | }, 1388 | { 1389 | "key": "x", 1390 | "name": "Font zooming transient", 1391 | "type": "command", 1392 | "command": "whichkey.showTransient", 1393 | "args": "whichkey.transient.fontZooming" 1394 | }, 1395 | { 1396 | "key": "i", 1397 | "name": "Image zooming transient", 1398 | "type": "command", 1399 | "command": "whichkey.showTransient", 1400 | "args": "whichkey.transient.imageZooming" 1401 | } 1402 | ] 1403 | }, 1404 | { 1405 | "key": "!", 1406 | "name": "Show terminal", 1407 | "type": "command", 1408 | "command": "workbench.action.terminal.focus" 1409 | }, 1410 | { 1411 | "key": "/", 1412 | "name": "Search in a project", 1413 | "type": "command", 1414 | "command": "workbench.action.findInFiles" 1415 | }, 1416 | { 1417 | "key": "*", 1418 | "name": "Search in a project with a selection", 1419 | "type": "commands", 1420 | "commands": [ 1421 | "editor.action.addSelectionToNextFindMatch", 1422 | "workbench.action.findInFiles" 1423 | ] 1424 | }, 1425 | { 1426 | "key": "v", 1427 | "name": "Smart select/expand region transient", 1428 | "type": "commands", 1429 | "commands": [ 1430 | "editor.action.smartSelect.grow", 1431 | "whichkey.showTransient" 1432 | ], 1433 | "args": [ 1434 | null, 1435 | "whichkey.transient.smartExpand" 1436 | ] 1437 | }, 1438 | { 1439 | "key": "1", 1440 | "name": "Focus 1st editor group", 1441 | "type": "command", 1442 | "command": "workbench.action.focusFirstEditorGroup" 1443 | }, 1444 | { 1445 | "key": "2", 1446 | "name": "Focus 2nd editor group", 1447 | "type": "command", 1448 | "command": "workbench.action.focusSecondEditorGroup" 1449 | }, 1450 | { 1451 | "key": "3", 1452 | "name": "Focus 3rd editor group", 1453 | "type": "command", 1454 | "command": "workbench.action.focusThirdEditorGroup" 1455 | }, 1456 | { 1457 | "key": "4", 1458 | "name": "Focus 4th editor group", 1459 | "type": "command", 1460 | "command": "workbench.action.focusFourthEditorGroup" 1461 | }, 1462 | { 1463 | "key": "5", 1464 | "name": "Focus 5th editor group", 1465 | "type": "command", 1466 | "command": "workbench.action.focusFifthEditorGroup" 1467 | }, 1468 | { 1469 | "key": "6", 1470 | "name": "Focus 6th editor group", 1471 | "type": "command", 1472 | "command": "workbench.action.focusSixthEditorGroup" 1473 | }, 1474 | { 1475 | "key": "7", 1476 | "name": "Focus 7th editor group", 1477 | "type": "command", 1478 | "command": "workbench.action.focusSeventhEditorGroup" 1479 | }, 1480 | { 1481 | "key": "8", 1482 | "name": "Focus 8th editor group", 1483 | "type": "command", 1484 | "command": "workbench.action.focusEighthEditorGroup" 1485 | } 1486 | ] 1487 | }, 1488 | "whichkey.bindingOverrides": { 1489 | "type": "array", 1490 | "markdownDescription": "Overrides bindings of the (default) which key" 1491 | } 1492 | } 1493 | } 1494 | ] 1495 | }, 1496 | "scripts": { 1497 | "vscode:prepublish": "webpack --mode production", 1498 | "compile": "webpack --mode development", 1499 | "watch": "webpack --watch", 1500 | "format": "prettier --write .", 1501 | "format-check": "prettier --check .", 1502 | "lint": "eslint src --ext ts", 1503 | "test-compile": "tsc -p .", 1504 | "test": "npm run compile && npm run test-node && npm run test-web", 1505 | "test-node": "node ./dist/test/runTest-node.js", 1506 | "test-web": "node ./dist/test/runTest-web.js" 1507 | }, 1508 | "devDependencies": { 1509 | "@types/glob": "^7.1.3", 1510 | "@types/mocha": "^9.0.0", 1511 | "@types/node": "16.x", 1512 | "@types/vscode": "^1.45.0", 1513 | "@types/webpack-env": "^1.16.0", 1514 | "@typescript-eslint/eslint-plugin": "^5.3.0", 1515 | "@typescript-eslint/parser": "^5.3.0", 1516 | "@vscode/test-electron": "^2.1.2", 1517 | "@vscode/test-web": "^0.0.62", 1518 | "assert": "^2.0.0", 1519 | "eslint": "^8.1.0", 1520 | "eslint-config-prettier": "^8.3.0", 1521 | "glob": "^7.1.6", 1522 | "mocha": "^9.1.3", 1523 | "ovsx": "^0.3.0", 1524 | "prettier": "^2.5.1", 1525 | "process": "^0.11.10", 1526 | "ts-loader": "^9.2.6", 1527 | "typescript": "^4.2.3", 1528 | "vsce": "^2.6.3", 1529 | "webpack": "^5.76.0", 1530 | "webpack-cli": "^4.5.0" 1531 | } 1532 | } 1533 | -------------------------------------------------------------------------------- /src/bindingComparer.ts: -------------------------------------------------------------------------------- 1 | import { CharCode } from "./charCode"; 2 | import { ActionType, BindingItem } from "./config/bindingItem"; 3 | import { SortOrder } from "./constants"; 4 | 5 | function getType(b: BindingItem) { 6 | let type = b.type; 7 | if (b.bindings && type == ActionType.Conditional) { 8 | if ( 9 | b.bindings.every((x) => b.bindings && x.type === b.bindings[0].type) 10 | ) { 11 | type = b.bindings[0].type; 12 | } 13 | } 14 | return type; 15 | } 16 | 17 | function getTypeOrder(type: ActionType) { 18 | return type === ActionType.Bindings 19 | ? 1 20 | : // non-bindings type have precedence 21 | 0; 22 | } 23 | 24 | function getCustomKeyOrder(key: string) { 25 | // 1. Single Key (a, A, z, Z, etc) 26 | // 2. Function key (f11, F11, etc) 27 | // 3. Modifier key (C-z, etc) 28 | // 4. Others 29 | let functionKeyMatch; 30 | if (Array.from(key).length === 1) { 31 | return 0; 32 | } else if ((functionKeyMatch = key.match(/^[fF]([1-9][0-9]?)$/))) { 33 | // Function key 34 | // 35 | // Sort function key in place so it will order from F1 - F35 instead of F1, F11, F2. 36 | // Also assuming it won't have more than 100 function keys. 37 | return +functionKeyMatch[1]; 38 | } else if (/.+-.+/g.test(key)) { 39 | // Modifier key 40 | return 100; 41 | } else { 42 | // Other 43 | return 101; 44 | } 45 | } 46 | 47 | /** 48 | * Create a custom to map to remap code point for sorting in the following order. 49 | * 50 | * 1. SPC 51 | * 2. Non-printable characters 52 | * 3. DEL 53 | * 4. ASCII symbols 54 | * 5. Number 55 | * 6. a-z 56 | * 5. A-Z 57 | * 6. Non-ASCII 58 | * @returns A custom map to remap code point for custom ordering. 59 | */ 60 | function createCustomCodePointMap() { 61 | let curMappedTo = 0; 62 | const map = new Map(); 63 | const add = (codes: [number, number]) => { 64 | const [from, to] = codes; 65 | for (let i = from; i <= to; i++) { 66 | map.set(i, curMappedTo++); 67 | } 68 | }; 69 | const seq: [number, number][] = [ 70 | [CharCode.Space, CharCode.Space], 71 | [CharCode.Null, CharCode.UnitSeparator], 72 | [CharCode.Delete, CharCode.Delete], 73 | [CharCode.Exclamation, CharCode.Slash], 74 | [CharCode.Colon, CharCode.At], 75 | [CharCode.LeftBracket, CharCode.Backtick], 76 | [CharCode.LeftBrace, CharCode.Tide], 77 | [CharCode.Zero, CharCode.Nine], 78 | [CharCode.a, CharCode.z], 79 | [CharCode.A, CharCode.Z], 80 | ]; 81 | seq.forEach(add); 82 | 83 | return map; 84 | } 85 | 86 | /** 87 | * A cached custom code point map. 88 | */ 89 | let customCodePointMap: Map | undefined = undefined; 90 | 91 | /** 92 | * Get order for each character in the following order. 93 | * 94 | * 1. SPC 95 | * 2. Non-printable characters 96 | * 3. DEL 97 | * 4. ASCII symbols 98 | * 5. Number 99 | * 6. a-z 100 | * 5. A-Z 101 | * 6. Non-ASCII 102 | * @param {string} a A single character string 103 | * @returns A shifted code point for ordering 104 | */ 105 | function getCustomCodePointOrder(a: string) { 106 | const codePoint = a.codePointAt(0) ?? 0; 107 | customCodePointMap = customCodePointMap ?? createCustomCodePointMap(); 108 | return customCodePointMap.get(codePoint) ?? codePoint; 109 | } 110 | 111 | function compareKeyString( 112 | codePointOrderFn: (s: string) => number, 113 | a: string, 114 | b: string 115 | ) { 116 | const aCodePoint = Array.from(a).map(codePointOrderFn); 117 | const bCodePoint = Array.from(b).map(codePointOrderFn); 118 | const len = Math.min(a.length, b.length); 119 | for (let i = 0; i < len; i++) { 120 | // Swap the case of the letter to sort lower case character first 121 | const diff = aCodePoint[i] - bCodePoint[i]; 122 | if (diff !== 0) { 123 | return diff; 124 | } 125 | } 126 | 127 | return aCodePoint.length - bCodePoint.length; 128 | } 129 | 130 | /** 131 | * Custom comparer. 132 | * 133 | * This comparer sorts the bindings by its key in different "category" then by a custom order within each "category". 134 | * 135 | * The category order is as follows: 136 | * 1. Single Key (a, z, SPC, TAB, etc) 137 | * 2. Function key (f11, F11, etc) 138 | * 3. Modifier key (C-z, etc) 139 | * 4. Others 140 | * 141 | * For non function key, the sort order of each character of the key is as: 142 | * 1. SPC 143 | * 2. Non-printable characters 144 | * 3. DEL 145 | * 4. ASCII symbols 146 | * 5. Number 147 | * 6. a-z 148 | * 7. A-Z 149 | * 8. Non-ASCII 150 | * 151 | * For function key, the sort order is as follows: 152 | * F1, F2, ..., F11, F12, ..., F99 153 | */ 154 | export function customComparer(a: BindingItem, b: BindingItem) { 155 | const diff = getCustomKeyOrder(a.key) - getCustomKeyOrder(b.key); 156 | if (diff !== 0) { 157 | return diff; 158 | } 159 | return compareKeyString(getCustomCodePointOrder, a.key, b.key); 160 | } 161 | 162 | /** 163 | * Type then custom comparer. 164 | * 165 | * This comparer sorts bindings by their type then by custom comparer. 166 | */ 167 | export function typeThenCustomComparer(a: BindingItem, b: BindingItem) { 168 | const diff = getTypeOrder(getType(a)) - getTypeOrder(getType(b)); 169 | if (diff !== 0) { 170 | return diff; 171 | } 172 | return customComparer(a, b); 173 | } 174 | 175 | /** 176 | * Alphabetical comparer. 177 | * 178 | * This comparer sorts bindings by their key alphabetically via `String.prototype.localeCompare()`. 179 | */ 180 | export function alphabeticalComparer(a: BindingItem, b: BindingItem) { 181 | return a.key.localeCompare(b.key); 182 | } 183 | 184 | /** 185 | * Non-number first comparer. 186 | * 187 | * This comparer sorts the bindings by its key in different "category" then by a {@link comparer} within each "category". 188 | * 189 | * The category order is as follows: 190 | * 1. Non-number key 191 | * 2. Number key 192 | */ 193 | export function nonNumberFirstComparer( 194 | comparer: (a: BindingItem, b: BindingItem) => number, 195 | a: BindingItem, 196 | b: BindingItem 197 | ) { 198 | const regex = /^[0-9]/; 199 | const aStartsWithNumber = regex.test(a.key); 200 | const bStartsWithNumber = regex.test(b.key); 201 | if (aStartsWithNumber !== bStartsWithNumber) { 202 | // Sort non-number first 203 | return aStartsWithNumber ? 1 : -1; 204 | } else { 205 | return comparer(a, b); 206 | } 207 | } 208 | 209 | export function getSortComparer(order: SortOrder) { 210 | switch (order) { 211 | case SortOrder.Custom: 212 | return customComparer; 213 | case SortOrder.CustomNonNumberFirst: 214 | // Keys with non-number alphabetically via custom comparer. 215 | return nonNumberFirstComparer.bind(null, customComparer); 216 | case SortOrder.TypeThenCustom: 217 | return typeThenCustomComparer; 218 | case SortOrder.Alphabetically: 219 | return alphabeticalComparer; 220 | case SortOrder.NonNumberFirst: 221 | // Keys with non-number alphabetically via `String.prototype.localeCompare()`. 222 | return nonNumberFirstComparer.bind(null, alphabeticalComparer); 223 | default: 224 | return undefined; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/charCode.ts: -------------------------------------------------------------------------------- 1 | export const enum CharCode { 2 | /** 3 | * The NUL character. 4 | */ 5 | Null = 0, 6 | /** 7 | * The `\t` character. 8 | */ 9 | Tab = 9, 10 | /** 11 | * The Unit Separator character. 12 | */ 13 | UnitSeparator = 31, 14 | /** 15 | * The ` ` character. 16 | */ 17 | Space = 32, 18 | 19 | /** 20 | * The `!` character. 21 | */ 22 | Exclamation = 33, 23 | /** 24 | * The `/` character. 25 | */ 26 | Slash = 47, 27 | 28 | Zero = 48, 29 | Nine = 57, 30 | 31 | /** 32 | * The `:` character. 33 | */ 34 | Colon = 58, 35 | 36 | /** 37 | * The `@` character. 38 | */ 39 | At = 64, 40 | 41 | A = 65, 42 | Z = 90, 43 | 44 | /** 45 | * The `[` character. 46 | */ 47 | LeftBracket = 91, 48 | 49 | /** 50 | * The `\` character. 51 | */ 52 | Backslash = 92, 53 | 54 | /** 55 | * The ` character. 56 | */ 57 | Backtick = 96, 58 | 59 | a = 97, 60 | z = 122, 61 | 62 | /** 63 | * The `{` character. 64 | */ 65 | LeftBrace = 123, 66 | 67 | /** 68 | * The `~` character. 69 | */ 70 | Tide = 126, 71 | 72 | /** 73 | * The DEL character. 74 | */ 75 | Delete = 127, 76 | } 77 | -------------------------------------------------------------------------------- /src/commandRelay.ts: -------------------------------------------------------------------------------- 1 | import { Event, EventEmitter } from "vscode"; 2 | 3 | export interface KeybindingArgs { 4 | key: string; 5 | when?: string; 6 | } 7 | 8 | export class CommandRelay { 9 | private keyEmitter: EventEmitter; 10 | private zenModeEmitter: EventEmitter; 11 | private searchBindingsEmitter: EventEmitter; 12 | private undoKeyEmitter: EventEmitter; 13 | 14 | constructor() { 15 | this.keyEmitter = new EventEmitter(); 16 | this.zenModeEmitter = new EventEmitter(); 17 | this.searchBindingsEmitter = new EventEmitter(); 18 | this.undoKeyEmitter = new EventEmitter(); 19 | } 20 | 21 | triggerKey(key: string | KeybindingArgs): void { 22 | if (typeof key === "string") { 23 | key = { key }; 24 | } 25 | this.keyEmitter.fire(key); 26 | } 27 | 28 | get onDidKeyPressed(): Event { 29 | return this.keyEmitter.event; 30 | } 31 | 32 | toggleZenMode(): void { 33 | this.zenModeEmitter.fire(); 34 | } 35 | 36 | get onDidToggleZenMode(): Event { 37 | return this.zenModeEmitter.event; 38 | } 39 | 40 | searchBindings(): void { 41 | this.searchBindingsEmitter.fire(); 42 | } 43 | 44 | get onDidSearchBindings(): Event { 45 | return this.searchBindingsEmitter.event; 46 | } 47 | 48 | undoKey(): void { 49 | this.undoKeyEmitter.fire(); 50 | } 51 | 52 | get onDidUndoKey(): Event { 53 | return this.undoKeyEmitter.event; 54 | } 55 | 56 | dispose(): void { 57 | this.keyEmitter.dispose(); 58 | this.zenModeEmitter.dispose(); 59 | this.searchBindingsEmitter.dispose(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/config/bindingItem.ts: -------------------------------------------------------------------------------- 1 | export const enum ActionType { 2 | Command = "command", 3 | Commands = "commands", 4 | Bindings = "bindings", 5 | Transient = "transient", 6 | Conditional = "conditional", 7 | } 8 | 9 | export const enum DisplayOption { 10 | Hidden = "hidden", 11 | } 12 | 13 | export interface CommandItem { 14 | command?: string; 15 | commands?: string[]; 16 | args?: any; 17 | } 18 | 19 | export interface BindingItem extends CommandItem { 20 | key: string; 21 | name: string; 22 | icon?: string; 23 | display?: DisplayOption; 24 | type: ActionType; 25 | bindings?: BindingItem[]; 26 | } 27 | 28 | export interface OverrideBindingItem extends CommandItem { 29 | keys: string | string[]; 30 | position?: number; 31 | name?: string; 32 | icon?: string; 33 | display?: DisplayOption; 34 | type?: ActionType; 35 | bindings?: BindingItem[]; 36 | } 37 | 38 | export interface TransientBindingItem extends CommandItem { 39 | key: string; 40 | name: string; 41 | icon?: string; 42 | display?: DisplayOption; 43 | exit?: boolean; 44 | } 45 | 46 | export function toBindingItem(o: any): BindingItem | undefined { 47 | if (typeof o === "object") { 48 | const config = o as Partial; 49 | if (config.key && config.name && config.type) { 50 | return config as BindingItem; 51 | } 52 | } 53 | return undefined; 54 | } 55 | 56 | export function toCommands(b: CommandItem): { 57 | commands: string[]; 58 | args: any; 59 | } { 60 | let commands: string[]; 61 | let args; 62 | if (b.commands) { 63 | commands = b.commands; 64 | args = b.args; 65 | } else if (b.command) { 66 | commands = [b.command]; 67 | args = [b.args]; 68 | } else { 69 | commands = []; 70 | args = []; 71 | } 72 | 73 | return { commands, args }; 74 | } 75 | -------------------------------------------------------------------------------- /src/config/condition.ts: -------------------------------------------------------------------------------- 1 | export interface Condition { 2 | when?: string; 3 | languageId?: string; 4 | } 5 | 6 | export function getCondition(key?: string): Condition | undefined { 7 | if (key && key.length > 0) { 8 | const props = key.split(";"); 9 | const r = props.reduce((result, prop) => { 10 | const [key, value] = prop.split(":"); 11 | result[key] = value; 12 | return result; 13 | }, {} as Record); 14 | 15 | // Check to make sure at least the one property so we don't create 16 | // { when: undefined, languagedId: undefined } 17 | if ("when" in r || "languageId" in r) { 18 | return { 19 | when: r["when"], 20 | languageId: r["languageId"], 21 | }; 22 | } 23 | } 24 | return undefined; 25 | } 26 | 27 | export function evalCondition( 28 | stored?: Condition, 29 | evaluatee?: Condition 30 | ): boolean { 31 | if (evaluatee && stored) { 32 | let result = true; 33 | if (stored.when) { 34 | result = result && stored.when === evaluatee.when; 35 | } 36 | if (stored.languageId) { 37 | result = result && stored.languageId === evaluatee.languageId; 38 | } 39 | return result; 40 | } 41 | // For if they are both undefined or null 42 | return stored === evaluatee; 43 | } 44 | 45 | export function isConditionEqual( 46 | condition1?: Condition, 47 | condition2?: Condition 48 | ): boolean { 49 | if (condition1 && condition2) { 50 | let result = true; 51 | result = result && condition1.when === condition2.when; 52 | result = result && condition1.languageId === condition2.languageId; 53 | return result; 54 | } 55 | // For if they are both undefined or null 56 | return condition1 === condition2; 57 | } 58 | 59 | export function isConditionKeyEqual(key1?: string, key2?: string): boolean { 60 | return isConditionEqual(getCondition(key1), getCondition(key2)); 61 | } 62 | -------------------------------------------------------------------------------- /src/config/menuConfig.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from "../utils"; 2 | import { BindingItem, TransientBindingItem } from "./bindingItem"; 3 | 4 | export type MaybeConfig = T | string | undefined; 5 | 6 | export interface WhichKeyMenuConfig { 7 | title?: string; 8 | delay: number; 9 | showIcons: boolean; 10 | showButtons: boolean; 11 | useFullWidthCharacters: boolean; 12 | bindings: BindingItem[]; 13 | } 14 | 15 | export interface TransientMenuConfig { 16 | title?: string; 17 | showIcons?: boolean; 18 | useFullWidthCharacters?: boolean; 19 | bindings: TransientBindingItem[]; 20 | } 21 | 22 | export function resolveMaybeConfig(b?: MaybeConfig): T | undefined { 23 | if (typeof b === "string") { 24 | return getConfig(b); 25 | } else { 26 | return b; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/config/whichKeyConfig.ts: -------------------------------------------------------------------------------- 1 | import { Configs } from "../constants"; 2 | 3 | type ConfigSections = [string, string]; 4 | 5 | export interface WhichKeyConfig { 6 | bindings: string; 7 | overrides?: string; 8 | title?: string; 9 | } 10 | 11 | function isString(x: any): x is string { 12 | return typeof x === "string"; 13 | } 14 | 15 | function isConfigSections(x: any): x is ConfigSections { 16 | return ( 17 | x && 18 | Array.isArray(x) && 19 | x.length === 2 && 20 | isString(x[0]) && 21 | isString(x[1]) 22 | ); 23 | } 24 | 25 | function isWhichKeyConfig(config: any): config is WhichKeyConfig { 26 | return ( 27 | config.bindings && 28 | isString(config.bindings) && 29 | (!config.overrides || isString(config.overrides)) && 30 | (!config.title || isString(config.title)) 31 | ); 32 | } 33 | 34 | function getFullSection(sections: ConfigSections): string { 35 | return `${sections[0]}.${sections[1]}`; 36 | } 37 | 38 | export function toWhichKeyConfig(o: any): WhichKeyConfig | undefined { 39 | if (typeof o === "object") { 40 | if (o.bindings && isConfigSections(o.bindings)) { 41 | o.bindings = getFullSection(o.bindings); 42 | } 43 | if (o.overrides && isConfigSections(o.overrides)) { 44 | o.overrides = getFullSection(o.overrides); 45 | } 46 | if (isWhichKeyConfig(o)) { 47 | return o; 48 | } 49 | } 50 | return undefined; 51 | } 52 | 53 | export const defaultWhichKeyConfig: WhichKeyConfig = { 54 | bindings: Configs.Bindings, 55 | overrides: Configs.Overrides, 56 | }; 57 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const extensionId = "VSpaceCode.whichkey"; 2 | export const contributePrefix = "whichkey"; 3 | export enum ConfigKey { 4 | Delay = "delay", 5 | ShowIcons = "showIcons", 6 | ShowButtons = "showButtons", 7 | UseFullWidthCharacters = "useFullWidthCharacters", 8 | SortOrder = "sortOrder", 9 | Bindings = "bindings", 10 | Overrides = "bindingOverrides", 11 | } 12 | export enum CommandKey { 13 | Show = "show", 14 | Register = "register", 15 | Trigger = "triggerKey", 16 | UndoKey = "undoKey", 17 | SearchBindings = "searchBindings", 18 | ShowTransient = "showTransient", 19 | RepeatRecent = "repeatRecent", 20 | RepeatMostRecent = "repeatMostRecent", 21 | ToggleZenMode = "toggleZenMode", 22 | OpenFile = "openFile", 23 | } 24 | 25 | export enum SortOrder { 26 | None = "none", 27 | Custom = "custom", 28 | CustomNonNumberFirst = "customNonNumberFirst", 29 | TypeThenCustom = "typeThenCustom", 30 | Alphabetically = "alphabetically", 31 | NonNumberFirst = "nonNumberFirst", 32 | } 33 | 34 | export enum ContextKey { 35 | Active = "whichkeyActive", 36 | Visible = "whichkeyVisible", 37 | TransientVisible = "transientVisible", 38 | } 39 | 40 | export const Configs = { 41 | Delay: `${contributePrefix}.${ConfigKey.Delay}`, 42 | ShowIcons: `${contributePrefix}.${ConfigKey.ShowIcons}`, 43 | ShowButtons: `${contributePrefix}.${ConfigKey.ShowButtons}`, 44 | UseFullWidthCharacters: `${contributePrefix}.${ConfigKey.UseFullWidthCharacters}`, 45 | SortOrder: `${contributePrefix}.${ConfigKey.SortOrder}`, 46 | Bindings: `${contributePrefix}.${ConfigKey.Bindings}`, 47 | Overrides: `${contributePrefix}.${ConfigKey.Overrides}`, 48 | }; 49 | 50 | export const Commands = { 51 | Show: `${contributePrefix}.${CommandKey.Show}`, 52 | Register: `${contributePrefix}.${CommandKey.Register}`, 53 | Trigger: `${contributePrefix}.${CommandKey.Trigger}`, 54 | UndoKey: `${contributePrefix}.${CommandKey.UndoKey}`, 55 | SearchBindings: `${contributePrefix}.${CommandKey.SearchBindings}`, 56 | ShowTransient: `${contributePrefix}.${CommandKey.ShowTransient}`, 57 | RepeatRecent: `${contributePrefix}.${CommandKey.RepeatRecent}`, 58 | RepeatMostRecent: `${contributePrefix}.${CommandKey.RepeatMostRecent}`, 59 | ToggleZen: `${contributePrefix}.${CommandKey.ToggleZenMode}`, 60 | OpenFile: `${contributePrefix}.${CommandKey.OpenFile}`, 61 | }; 62 | -------------------------------------------------------------------------------- /src/dispatchQueue.ts: -------------------------------------------------------------------------------- 1 | export class DispatchQueue { 2 | private _queue: T[]; 3 | private _isProcessing; 4 | private _receiver: (item: T) => Promise; 5 | 6 | constructor(receiver: (item: T) => Promise) { 7 | this._queue = []; 8 | this._isProcessing = false; 9 | this._receiver = receiver; 10 | } 11 | 12 | get length() { 13 | return this._queue.length; 14 | } 15 | 16 | push(item: T) { 17 | this._queue.push(item); 18 | this._receive(); 19 | } 20 | 21 | clear() { 22 | this._queue.length = 0; 23 | } 24 | 25 | private async _receive() { 26 | if (this._isProcessing) { 27 | // Skip if one is already executing. 28 | return; 29 | } 30 | 31 | this._isProcessing = true; 32 | let item; 33 | while ((item = this._queue.shift())) { 34 | await this._receiver(item); 35 | } 36 | this._isProcessing = false; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import { commands, ExtensionContext } from "vscode"; 2 | import { CommandRelay } from "./commandRelay"; 3 | import { Commands } from "./constants"; 4 | import { showTransientMenu } from "./menu/transientMenu"; 5 | import { StatusBar } from "./statusBar"; 6 | import { WhichKeyRegistry } from "./whichKeyRegistry"; 7 | 8 | async function openFile(): Promise { 9 | try { 10 | await commands.executeCommand("workbench.action.files.openFile"); 11 | } catch { 12 | // Mac only command 13 | // https://github.com/microsoft/vscode/issues/5437#issuecomment-211500871 14 | await commands.executeCommand("workbench.action.files.openFileFolder"); 15 | } 16 | } 17 | 18 | export function activate(context: ExtensionContext): void { 19 | const statusBar = new StatusBar(); 20 | const cmdRelay = new CommandRelay(); 21 | const registry = new WhichKeyRegistry(statusBar, cmdRelay); 22 | 23 | context.subscriptions.push( 24 | commands.registerCommand( 25 | Commands.Trigger, 26 | cmdRelay.triggerKey, 27 | cmdRelay 28 | ), 29 | commands.registerCommand(Commands.UndoKey, cmdRelay.undoKey, cmdRelay), 30 | commands.registerCommand( 31 | Commands.Register, 32 | registry.register, 33 | registry 34 | ), 35 | commands.registerCommand(Commands.Show, registry.show, registry), 36 | commands.registerCommand( 37 | Commands.SearchBindings, 38 | cmdRelay.searchBindings, 39 | cmdRelay 40 | ), 41 | commands.registerCommand( 42 | Commands.ShowTransient, 43 | showTransientMenu.bind(registry, statusBar, cmdRelay) 44 | ), 45 | commands.registerCommand( 46 | Commands.RepeatRecent, 47 | registry.repeatRecent, 48 | registry 49 | ), 50 | commands.registerCommand( 51 | Commands.RepeatMostRecent, 52 | registry.repeatMostRecent, 53 | registry 54 | ), 55 | commands.registerCommand( 56 | Commands.ToggleZen, 57 | cmdRelay.toggleZenMode, 58 | cmdRelay 59 | ), 60 | commands.registerCommand(Commands.OpenFile, openFile) 61 | ); 62 | 63 | context.subscriptions.push(registry, cmdRelay, statusBar); 64 | } 65 | -------------------------------------------------------------------------------- /src/menu/baseWhichKeyMenu.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Disposable, 3 | Event, 4 | EventEmitter, 5 | QuickInputButton, 6 | QuickPick, 7 | QuickPickItem, 8 | version, 9 | window, 10 | } from "vscode"; 11 | import { CommandRelay, KeybindingArgs } from "../commandRelay"; 12 | import { DispatchQueue } from "../dispatchQueue"; 13 | import { ComparisonResult, Version } from "../version"; 14 | 15 | export interface BaseWhichKeyMenuItem { 16 | key: string; 17 | } 18 | 19 | export interface BaseWhichKeyQuickPickItem 20 | extends QuickPickItem { 21 | item: T; 22 | } 23 | 24 | export interface BaseWhichKeyMenuState { 25 | title?: string; 26 | items: T[]; 27 | delay: number; 28 | showMenu: boolean; 29 | buttons?: QuickInputButton[]; 30 | } 31 | 32 | export type OptionalBaseWhichKeyMenuState = 33 | | BaseWhichKeyMenuState 34 | | undefined; 35 | 36 | export abstract class BaseWhichKeyMenu 37 | implements Disposable 38 | { 39 | private _acceptQueue: DispatchQueue; 40 | private _valueQueue: DispatchQueue; 41 | 42 | private _qp: QuickPick>; 43 | private _when?: string; 44 | private _lastValue: string; 45 | private _expectHiding: boolean; 46 | private _state: BaseWhichKeyMenuState; 47 | private _timeoutId?: NodeJS.Timeout; 48 | private _disposables: Disposable[]; 49 | 50 | private _onDidShowEmitter: EventEmitter; 51 | private _onDidHideEmitter: EventEmitter; 52 | private _onDisposeEmitter: EventEmitter; 53 | 54 | onDidResolve?: () => any; 55 | onDidReject?: (reason?: any) => any; 56 | 57 | showButtons = true; 58 | 59 | constructor(cmdRelay: CommandRelay) { 60 | this._acceptQueue = new DispatchQueue( 61 | this.handleAcceptanceDispatch.bind(this) 62 | ); 63 | this._valueQueue = new DispatchQueue( 64 | this.handleValueDispatch.bind(this) 65 | ); 66 | this._lastValue = ""; 67 | this._expectHiding = false; 68 | 69 | // Setup initial state 70 | this._state = { 71 | delay: 0, 72 | items: [], 73 | showMenu: false, 74 | }; 75 | 76 | this._qp = window.createQuickPick>(); 77 | this._onDidShowEmitter = new EventEmitter(); 78 | this._onDidHideEmitter = new EventEmitter(); 79 | this._onDisposeEmitter = new EventEmitter(); 80 | 81 | // setup on change value 82 | this._disposables = [ 83 | this._onDidShowEmitter, 84 | this._onDidHideEmitter, 85 | this._onDisposeEmitter, 86 | cmdRelay.onDidKeyPressed(this.handleDidKeyPressed, this), 87 | this._qp.onDidAccept(this.handleDidAccept, this), 88 | this._qp.onDidChangeValue(this.handleDidChangeValue, this), 89 | this._qp.onDidHide(this.handleDidHide, this), 90 | this._qp, 91 | ]; 92 | } 93 | 94 | /** 95 | * If this is true, setting `this.value` should call `this.handleDidChangeValue` to maintain backward compatibility. 96 | * 97 | * vscode 1.57+ changed API so setting QuickPick will trigger onDidChangeValue. 98 | * See https://github.com/microsoft/vscode/issues/122939. 99 | * 100 | */ 101 | private static shouldTriggerDidChangeValueOnSet = 102 | Version.parse(version).compare(new Version(1, 57, 0)) == 103 | ComparisonResult.Older; 104 | 105 | /** 106 | * Set the value of the QuicPick that's backward compatible. 107 | * 108 | * Note: This will call `this.handleDidChangeValue` either via 109 | * QuickPick's `onDidChangeValue` or manual call for backward compatibility. 110 | */ 111 | set value(val: string) { 112 | this._qp.value = val; 113 | if (BaseWhichKeyMenu.shouldTriggerDidChangeValueOnSet) { 114 | // Trigger handler manually to maintain backward compatibility. 115 | this.handleDidChangeValue(val); 116 | } 117 | } 118 | 119 | get value() { 120 | return this._qp.value; 121 | } 122 | 123 | get when() { 124 | return this._when; 125 | } 126 | 127 | get state() { 128 | return this._state; 129 | } 130 | 131 | get onDidShow(): Event { 132 | return this._onDidShowEmitter.event; 133 | } 134 | 135 | get onDidHide(): Event { 136 | return this._onDidHideEmitter.event; 137 | } 138 | 139 | get onDidTriggerButton(): Event { 140 | return this._qp.onDidTriggerButton; 141 | } 142 | 143 | get onDispose(): Event { 144 | return this._onDisposeEmitter.event; 145 | } 146 | 147 | private handleDidKeyPressed(arg: KeybindingArgs): void { 148 | // Enqueue directly instead of setting value like 149 | // `this.key = this._qp.value + arg.key` 150 | // because QuickPick might lump key together 151 | // and send only one combined event especially when 152 | // the keys are set programmatically. 153 | // 154 | // For example: 155 | // ``` 156 | // menu.show(); 157 | // cmdRelay.triggerKey('m'); 158 | // cmdRelay.triggerKey('x'); 159 | // ``` 160 | // or trigger via vscode vim re-mapper 161 | // ``` 162 | // { 163 | // "before": [","], 164 | // "commands": [ 165 | // "whichkey.show", 166 | // {"command": "whichkey.triggerKey", "args": "m"}, 167 | // {"command": "whichkey.triggerKey", "args": "x"} 168 | // ], 169 | // } 170 | // ``` 171 | this._when = arg.when; 172 | this._valueQueue.push(arg.key); 173 | } 174 | 175 | private handleDidAccept(): void { 176 | if (this._qp.activeItems.length > 0) { 177 | const qpItem = this._qp.activeItems[0]; 178 | this._acceptQueue.push(qpItem.item); 179 | } 180 | } 181 | 182 | private handleDidChangeValue(value: string): void { 183 | const last = this._lastValue; 184 | 185 | // Not handling the unchanged value 186 | if (value === last) { 187 | return; 188 | } 189 | 190 | // Prevent character deletion 191 | if (value.length > 0 && value.length < last.length) { 192 | // This set will triggered another onDidChangeValue 193 | this.value = last; 194 | return; 195 | } 196 | 197 | // Set _lastValue before correct the value for the queue 198 | this._lastValue = value; 199 | 200 | // Correct input value while it's processing in the queue 201 | // before pushing to queue. 202 | // 203 | // For example, an extra key is pressing while waiting on hiding when handling. 204 | // [last] Example input value -> Corrected value 205 | // [""] "a" -> "a" 206 | // ["a"] "ab" -> "b" 207 | // ["ab"] "abC-c" -> "C-c" 208 | if (value.startsWith(last)) { 209 | value = value.substr(last.length); 210 | } 211 | 212 | // QuickPick's onDidChangeValue wouldn't wait on async function, 213 | // so push input to a queue to handle all changes sequentially to 214 | // prevent race condition on key change when one input is still 215 | // processing (mostly when waiting on hiding). 216 | this._valueQueue.push(value); 217 | } 218 | 219 | private handleDidHide(): void { 220 | // Fire event _onDidHideEmitter before dispose 221 | // So hide event will be sent before dispose. 222 | this._onDidHideEmitter.fire(); 223 | 224 | if (!this._expectHiding) { 225 | this.dispose(); 226 | } 227 | this._expectHiding = false; 228 | } 229 | 230 | private clearDelay(): void { 231 | // Clear timeout can take undefined 232 | clearTimeout(this._timeoutId!); 233 | this._timeoutId = undefined; 234 | } 235 | 236 | private async handleValueDispatch(key: string): Promise { 237 | if (key.length > 0) { 238 | const item = this._state.items.find((i) => i.key === key); 239 | if (item) { 240 | await this.handleAcceptanceDispatch(item); 241 | } else { 242 | await this.handleMismatchDispatch(key); 243 | } 244 | } 245 | } 246 | 247 | private handleAcceptanceDispatch(item: T): Promise { 248 | return this.handleDispatch(this.handleAccept(item)); 249 | } 250 | 251 | private handleMismatchDispatch(key: string): Promise { 252 | return this.handleDispatch(this.handleMismatch(key)); 253 | } 254 | 255 | private async handleDispatch( 256 | nextState: Promise> 257 | ): Promise { 258 | try { 259 | const update = await nextState; 260 | if (update) { 261 | this.value = ""; 262 | this.update(update); 263 | this.show(); 264 | } else { 265 | this.resolve(); 266 | } 267 | } catch (e) { 268 | this.reject(e); 269 | } 270 | } 271 | 272 | /** 273 | * Handles an accepted item from either input or UI selection. 274 | * @param item The item begin accepted. 275 | */ 276 | protected abstract handleAccept( 277 | item: T 278 | ): Promise>; 279 | 280 | /** 281 | * Handles when no item matches the input. 282 | * @param key the key that was entered. 283 | */ 284 | protected abstract handleMismatch( 285 | key: string 286 | ): Promise>; 287 | 288 | /** 289 | * Handles the rendering of an menu item. 290 | * 291 | * This is primarily used to control which, what, how menu items should 292 | * be shown in the forms of QuickPickItem. 293 | * @param items The menu items to render. 294 | */ 295 | protected abstract handleRender(items: T[]): BaseWhichKeyQuickPickItem[]; 296 | 297 | /** 298 | * Updates the menu base on the state supplied. 299 | * @param state The state used to update the menu. 300 | */ 301 | update(state: BaseWhichKeyMenuState): void { 302 | this.clearDelay(); 303 | this._qp.title = state.title; 304 | this._qp.buttons = this.showButtons ? state.buttons ?? [] : []; 305 | // Need clear the current rendered menu items 306 | // when user click the back button with delay 307 | // so we won't show the old menu items while the menu is 308 | // waiting to be displayed on delay. 309 | // 310 | // It worked without clearing for non-back button is because 311 | // the menu items has been filtered when the key was entered. 312 | // See https://github.com/microsoft/vscode/issues/137279 313 | this._qp.items = []; 314 | this._state = state; 315 | 316 | if (state.showMenu) { 317 | this._qp.busy = true; 318 | this._timeoutId = setTimeout(() => { 319 | this._qp.busy = false; 320 | this._qp.items = this.handleRender(state.items); 321 | }, state.delay); 322 | } 323 | } 324 | 325 | private resolve(): void { 326 | this.onDidResolve?.(); 327 | this.dispose(); 328 | } 329 | 330 | private reject(e: any): void { 331 | this.onDidReject?.(e); 332 | this.dispose(); 333 | } 334 | 335 | hide(): Promise { 336 | return new Promise((r) => { 337 | this._expectHiding = true; 338 | // Needs to wait onDidHide because 339 | // https://github.com/microsoft/vscode/issues/135747 340 | const disposable = this._qp.onDidHide(() => { 341 | this._expectHiding = false; 342 | disposable.dispose(); 343 | r(); 344 | }); 345 | this._qp.hide(); 346 | }); 347 | } 348 | 349 | show(): void { 350 | this._qp.show(); 351 | this._onDidShowEmitter.fire(); 352 | } 353 | 354 | dispose(): void { 355 | this.clearDelay(); 356 | this._valueQueue.clear(); 357 | this._acceptQueue.clear(); 358 | 359 | this._onDisposeEmitter.fire(); 360 | for (const d of this._disposables) { 361 | d.dispose(); 362 | } 363 | 364 | // Call onDidResolve once again in case dispose 365 | // was not called from resolve or reject. 366 | this.onDidResolve?.(); 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/menu/descBindMenu.ts: -------------------------------------------------------------------------------- 1 | import { Disposable, QuickPick, window } from "vscode"; 2 | import { DispatchQueue } from "../dispatchQueue"; 3 | import { executeCommands } from "../utils"; 4 | import { DescBindMenuItem } from "./descBindMenuItem"; 5 | 6 | class DescBindMenu implements Disposable { 7 | private _disposables: Disposable[]; 8 | private _qp: QuickPick; 9 | private _expectHiding: boolean; 10 | private _acceptQueue: DispatchQueue; 11 | 12 | onDidResolve?: () => any; 13 | onDidReject?: (reason?: any) => any; 14 | 15 | constructor(quickPick: QuickPick) { 16 | this._acceptQueue = new DispatchQueue(this.accept.bind(this)); 17 | this._expectHiding = false; 18 | this._qp = quickPick; 19 | 20 | this._disposables = [ 21 | this._qp.onDidAccept(this.handleDidAccept, this), 22 | this._qp.onDidHide(this.handleDidHide, this), 23 | this._qp, 24 | ]; 25 | } 26 | 27 | private handleDidAccept() { 28 | if (this._qp.activeItems.length > 0) { 29 | const item = this._qp.activeItems[0]; 30 | this._acceptQueue.push(item); 31 | } 32 | } 33 | 34 | private async accept(val: DescBindMenuItem) { 35 | try { 36 | this._qp.value = ""; 37 | if (val.commands) { 38 | await this.hide(); 39 | await executeCommands(val.commands, val.args); 40 | } 41 | 42 | if (val.items) { 43 | this._qp.placeholder = val.description; 44 | this._qp.items = val.items; 45 | this.show(); 46 | } else { 47 | this.resolve(); 48 | } 49 | } catch (e) { 50 | this.reject(e); 51 | } 52 | } 53 | 54 | private resolve(): void { 55 | this.onDidResolve?.(); 56 | this.dispose(); 57 | } 58 | 59 | private reject(e: any): void { 60 | this.onDidReject?.(e); 61 | this.dispose(); 62 | } 63 | 64 | private handleDidHide() { 65 | if (!this._expectHiding) { 66 | this.dispose(); 67 | } 68 | this._expectHiding = false; 69 | } 70 | 71 | hide(): Promise { 72 | return new Promise((r) => { 73 | this._expectHiding = true; 74 | // Needs to wait onDidHide because 75 | // https://github.com/microsoft/vscode/issues/135747 76 | const disposable = this._qp.onDidHide(() => { 77 | this._expectHiding = false; 78 | disposable.dispose(); 79 | r(); 80 | }); 81 | this._qp.hide(); 82 | }); 83 | } 84 | 85 | show() { 86 | this._qp.show(); 87 | } 88 | 89 | dispose(): void { 90 | this._acceptQueue.clear(); 91 | for (const d of this._disposables) { 92 | d.dispose(); 93 | } 94 | 95 | // Call onDidResolve once again in case dispose were not call from resolve and reject 96 | this.onDidResolve?.(); 97 | } 98 | } 99 | 100 | export function showDescBindMenu( 101 | items: DescBindMenuItem[], 102 | title?: string 103 | ): Promise { 104 | return new Promise((resolve, reject) => { 105 | const qp = window.createQuickPick(); 106 | qp.matchOnDescription = true; 107 | qp.matchOnDetail = true; 108 | qp.items = items; 109 | qp.title = title; 110 | const menu = new DescBindMenu(qp); 111 | menu.onDidResolve = resolve; 112 | menu.onDidReject = reject; 113 | menu.show(); 114 | }); 115 | } 116 | -------------------------------------------------------------------------------- /src/menu/descBindMenuItem.ts: -------------------------------------------------------------------------------- 1 | import { QuickPickItem } from "vscode"; 2 | import { BindingItem, toCommands } from "../config/bindingItem"; 3 | import { getCondition } from "../config/condition"; 4 | import { toFullWidthSpecializedKey } from "../utils"; 5 | 6 | function pathToMenuLabel(path: BindingItem[]): string { 7 | return ( 8 | path 9 | // Filter all the condition key so we will not show something like 10 | // languageId:markdown 11 | .filter((item) => !getCondition(item.key)) 12 | .map((item) => toFullWidthSpecializedKey(item.key)) 13 | .join(" ") 14 | ); 15 | } 16 | 17 | function pathToMenuDetail(path: BindingItem[]): string { 18 | return path.map((p) => p.name).join("$(chevron-right)"); 19 | } 20 | 21 | function conversion( 22 | i: BindingItem, 23 | prefixPath: BindingItem[] = [] 24 | ): DescBindMenuItem { 25 | const newPath = prefixPath.concat(i); 26 | 27 | const item: DescBindMenuItem = { 28 | label: pathToMenuLabel(newPath), 29 | detail: pathToMenuDetail(prefixPath), 30 | description: i.name, 31 | }; 32 | if (i.bindings) { 33 | // eslint-disable-next-line @typescript-eslint/no-use-before-define 34 | item.items = createDescBindItems(i.bindings, newPath); 35 | } else if (i.commands || i.command) { 36 | const { commands, args } = toCommands(i); 37 | item.commands = commands; 38 | item.args = args; 39 | } 40 | 41 | return item; 42 | } 43 | export interface DescBindMenuItem extends QuickPickItem { 44 | commands?: string[]; 45 | args?: string[]; 46 | items?: DescBindMenuItem[]; 47 | } 48 | 49 | export function createDescBindItems( 50 | items: readonly BindingItem[], 51 | path: BindingItem[] = [] 52 | ): DescBindMenuItem[] { 53 | const curr: DescBindMenuItem[] = []; 54 | const next: DescBindMenuItem[] = []; 55 | 56 | for (const i of items) { 57 | path = path.filter((p) => p.bindings); 58 | const menuItem = conversion(i, path); 59 | curr.push(menuItem); 60 | if (menuItem.items) { 61 | next.push(...menuItem.items); // concat in-place 62 | } 63 | } 64 | 65 | curr.push(...next); // concat in-place 66 | return curr; 67 | } 68 | -------------------------------------------------------------------------------- /src/menu/repeaterMenu.ts: -------------------------------------------------------------------------------- 1 | import { CommandRelay } from "../commandRelay"; 2 | import { StatusBar } from "../statusBar"; 3 | import { 4 | toFullWidthKey, 5 | toFullWidthSpecializedKey, 6 | toSpecializedKey, 7 | } from "../utils"; 8 | import { 9 | BaseWhichKeyMenu, 10 | BaseWhichKeyMenuItem, 11 | BaseWhichKeyQuickPickItem, 12 | OptionalBaseWhichKeyMenuState, 13 | } from "./baseWhichKeyMenu"; 14 | 15 | export interface RepeaterMenuItem extends BaseWhichKeyMenuItem { 16 | name: string; 17 | basePathNames: string[]; 18 | accept: () => Thenable; 19 | } 20 | 21 | export interface RepeaterMenuConfig { 22 | title?: string; 23 | useFullWidthCharacters: boolean; 24 | items: RepeaterMenuItem[]; 25 | } 26 | 27 | type OptionalRepeatMenuState = OptionalBaseWhichKeyMenuState; 28 | 29 | class RepeaterMenu extends BaseWhichKeyMenu { 30 | private _statusBar: StatusBar; 31 | 32 | useFullWidthCharacters = false; 33 | 34 | constructor(statusBar: StatusBar, cmdRelay: CommandRelay) { 35 | super(cmdRelay); 36 | this._statusBar = statusBar; 37 | } 38 | 39 | protected override async handleAccept( 40 | item: RepeaterMenuItem 41 | ): Promise { 42 | this._statusBar.hide(); 43 | 44 | await this.hide(); 45 | await item.accept(); 46 | return undefined; 47 | } 48 | 49 | protected override async handleMismatch( 50 | key: string 51 | ): Promise { 52 | const msg = `${toSpecializedKey(key)} is undefined`; 53 | this._statusBar.setErrorMessage(msg); 54 | return undefined; 55 | } 56 | 57 | protected override handleRender( 58 | items: RepeaterMenuItem[] 59 | ): BaseWhichKeyQuickPickItem[] { 60 | const max = items.reduce( 61 | (acc, val) => (acc > val.key.length ? acc : val.key.length), 62 | 0 63 | ); 64 | return items.map((i) => { 65 | const label = this.useFullWidthCharacters 66 | ? toFullWidthSpecializedKey(i.key) + 67 | toFullWidthKey(" ".repeat(max - i.key.length)) 68 | : toSpecializedKey(i.key); 69 | return { 70 | label, 71 | description: `\t${i.name}`, 72 | detail: i.basePathNames.join("$(chevron-right)"), 73 | item: i, 74 | }; 75 | }); 76 | } 77 | } 78 | 79 | export function showRepeaterMenu( 80 | statusBar: StatusBar, 81 | cmdRelay: CommandRelay, 82 | config: RepeaterMenuConfig 83 | ): Promise { 84 | return new Promise((resolve, reject) => { 85 | const menu = new RepeaterMenu(statusBar, cmdRelay); 86 | menu.useFullWidthCharacters = config.useFullWidthCharacters; 87 | menu.update({ 88 | title: config.title, 89 | items: config.items, 90 | delay: 0, 91 | showMenu: true, 92 | }); 93 | menu.onDidResolve = resolve; 94 | menu.onDidReject = reject; 95 | menu.show(); 96 | }); 97 | } 98 | -------------------------------------------------------------------------------- /src/menu/transientMenu.ts: -------------------------------------------------------------------------------- 1 | import { Disposable } from "vscode"; 2 | import { CommandRelay } from "../commandRelay"; 3 | import { 4 | DisplayOption, 5 | toCommands, 6 | TransientBindingItem, 7 | } from "../config/bindingItem"; 8 | import { 9 | MaybeConfig, 10 | resolveMaybeConfig, 11 | TransientMenuConfig, 12 | } from "../config/menuConfig"; 13 | import { Configs, ContextKey } from "../constants"; 14 | import { StatusBar } from "../statusBar"; 15 | import { 16 | executeCommands, 17 | getConfig, 18 | setContext, 19 | toFullWidthKey, 20 | toFullWidthSpecializedKey, 21 | toSpecializedKey, 22 | } from "../utils"; 23 | import { 24 | BaseWhichKeyMenu, 25 | BaseWhichKeyQuickPickItem, 26 | OptionalBaseWhichKeyMenuState, 27 | } from "./baseWhichKeyMenu"; 28 | 29 | type OptionalTransientMenuState = 30 | OptionalBaseWhichKeyMenuState; 31 | 32 | class TransientMenu extends BaseWhichKeyMenu { 33 | private _statusBar: StatusBar; 34 | private __disposables: Disposable[]; 35 | 36 | showIcon = true; 37 | useFullWidthCharacters = false; 38 | 39 | constructor(statusBar: StatusBar, cmdRelay: CommandRelay) { 40 | super(cmdRelay); 41 | this._statusBar = statusBar; 42 | this.__disposables = [ 43 | cmdRelay.onDidToggleZenMode(this.toggleZenMode, this), 44 | this.onDidHide(() => 45 | setContext(ContextKey.TransientVisible, false) 46 | ), 47 | this.onDidShow(() => setContext(ContextKey.TransientVisible, true)), 48 | ]; 49 | } 50 | 51 | protected override async handleAccept( 52 | item: TransientBindingItem 53 | ): Promise { 54 | await this.hide(); 55 | const { commands, args } = toCommands(item); 56 | await executeCommands(commands, args); 57 | 58 | return item.exit !== true ? this.state : undefined; 59 | } 60 | 61 | protected override async handleMismatch( 62 | key: string 63 | ): Promise { 64 | const msg = `${toSpecializedKey(key)} is undefined`; 65 | this._statusBar.setErrorMessage(msg); 66 | return undefined; 67 | } 68 | 69 | protected override handleRender( 70 | items: TransientBindingItem[] 71 | ): BaseWhichKeyQuickPickItem[] { 72 | items = items.filter((i) => i.display !== DisplayOption.Hidden); 73 | const max = items.reduce( 74 | (acc, val) => (acc > val.key.length ? acc : val.key.length), 75 | 0 76 | ); 77 | 78 | return items.map((i) => { 79 | const icon = 80 | this.showIcon && i.icon && i.icon.length > 0 81 | ? `$(${i.icon}) ` 82 | : ""; 83 | const label = this.useFullWidthCharacters 84 | ? toFullWidthSpecializedKey(i.key) + 85 | toFullWidthKey(" ".repeat(max - i.key.length)) 86 | : toSpecializedKey(i.key); 87 | return { 88 | label, 89 | description: `\t${icon}${i.name}`, 90 | item: i, 91 | }; 92 | }); 93 | } 94 | 95 | override dispose() { 96 | this._statusBar.hidePlain(); 97 | for (const d of this.__disposables) { 98 | d.dispose(); 99 | } 100 | 101 | super.dispose(); 102 | } 103 | 104 | toggleZenMode(): void { 105 | this.update({ 106 | ...this.state, 107 | showMenu: !this.state.showMenu, 108 | }); 109 | } 110 | } 111 | 112 | export function showTransientMenu( 113 | statusBar: StatusBar, 114 | cmdRelay: CommandRelay, 115 | config: MaybeConfig 116 | ): Promise { 117 | return new Promise((resolve, reject) => { 118 | const menuConfig = resolveMaybeConfig(config); 119 | const menu = new TransientMenu(statusBar, cmdRelay); 120 | menu.showIcon = 121 | menuConfig?.showIcons ?? 122 | getConfig(Configs.ShowIcons) ?? 123 | true; 124 | menu.useFullWidthCharacters = 125 | menuConfig?.useFullWidthCharacters ?? 126 | getConfig(Configs.UseFullWidthCharacters) ?? 127 | false; 128 | menu.onDidResolve = resolve; 129 | menu.onDidReject = reject; 130 | menu.update({ 131 | title: menuConfig?.title, 132 | items: menuConfig?.bindings ?? [], 133 | delay: 0, 134 | showMenu: true, 135 | }); 136 | menu.show(); 137 | }); 138 | } 139 | -------------------------------------------------------------------------------- /src/menu/whichKeyMenu.ts: -------------------------------------------------------------------------------- 1 | import { Disposable, QuickInputButton, ThemeIcon, window } from "vscode"; 2 | import { CommandRelay } from "../commandRelay"; 3 | import { 4 | ActionType, 5 | BindingItem, 6 | DisplayOption, 7 | toCommands, 8 | } from "../config/bindingItem"; 9 | import { Condition, evalCondition, getCondition } from "../config/condition"; 10 | import { WhichKeyMenuConfig } from "../config/menuConfig"; 11 | import { ContextKey } from "../constants"; 12 | import { StatusBar } from "../statusBar"; 13 | import { 14 | executeCommands, 15 | setContext, 16 | toFullWidthKey, 17 | toFullWidthSpecializedKey, 18 | toSpecializedKey, 19 | } from "../utils"; 20 | import { WhichKeyRepeater } from "../whichKeyRepeater"; 21 | import { 22 | BaseWhichKeyMenu, 23 | BaseWhichKeyMenuState, 24 | BaseWhichKeyQuickPickItem, 25 | OptionalBaseWhichKeyMenuState, 26 | } from "./baseWhichKeyMenu"; 27 | import { showDescBindMenu } from "./descBindMenu"; 28 | import { createDescBindItems } from "./descBindMenuItem"; 29 | 30 | type OptionalWhichKeyMenuState = OptionalBaseWhichKeyMenuState; 31 | 32 | function evalBindingCondition( 33 | item?: BindingItem, 34 | condition?: Condition 35 | ): BindingItem | undefined { 36 | while (item && item.type === ActionType.Conditional) { 37 | // Search the condition first. If no matches, find the first empty condition as else 38 | item = 39 | findBindingWithCondition(item.bindings, condition) ?? 40 | findBindingWithCondition(item.bindings, undefined); 41 | } 42 | return item; 43 | } 44 | 45 | function findBindingWithCondition( 46 | bindings?: BindingItem[], 47 | condition?: Condition 48 | ): BindingItem | undefined { 49 | return bindings?.find((i) => evalCondition(getCondition(i.key), condition)); 50 | } 51 | 52 | class WhichKeyMenu extends BaseWhichKeyMenu { 53 | private _stateHistory: BaseWhichKeyMenuState[]; 54 | private _itemHistory: BindingItem[]; 55 | private __disposables: Disposable[]; 56 | 57 | private _statusBar: StatusBar; 58 | private _repeater?: WhichKeyRepeater; 59 | 60 | useFullWidthCharacters = false; 61 | showIcons = true; 62 | delay = 0; 63 | 64 | constructor( 65 | statusBar: StatusBar, 66 | cmdRelay: CommandRelay, 67 | repeater?: WhichKeyRepeater 68 | ) { 69 | super(cmdRelay); 70 | this._statusBar = statusBar; 71 | this._repeater = repeater; 72 | 73 | this._stateHistory = []; 74 | this._itemHistory = []; 75 | this.__disposables = [ 76 | cmdRelay.onDidSearchBindings(this.handleDidSearchBindings, this), 77 | cmdRelay.onDidUndoKey(this.handleDidUndoKey, this), 78 | this.onDidTriggerButton(this.handleDidTriggerButton, this), 79 | this.onDidHide(() => setContext(ContextKey.Visible, false)), 80 | this.onDidShow(() => setContext(ContextKey.Visible, true)), 81 | ]; 82 | } 83 | 84 | static SearchBindingButton: QuickInputButton = { 85 | iconPath: new ThemeIcon("search"), 86 | tooltip: "Search keybindings", 87 | }; 88 | 89 | static UndoKeyButton: QuickInputButton = { 90 | iconPath: new ThemeIcon("arrow-left"), 91 | tooltip: "Undo key", 92 | }; 93 | 94 | private static NonRootMenuButtons = [ 95 | this.UndoKeyButton, 96 | this.SearchBindingButton, 97 | ]; 98 | 99 | private get condition(): Condition { 100 | const languageId = window.activeTextEditor?.document.languageId; 101 | return { 102 | when: this.when, 103 | languageId, 104 | }; 105 | } 106 | 107 | private handleDidSearchBindings(): Promise { 108 | const items = createDescBindItems(this.state.items, this._itemHistory); 109 | return showDescBindMenu(items, "Search Keybindings"); 110 | } 111 | 112 | private handleDidUndoKey() { 113 | const length = this._stateHistory.length; 114 | if (length > 1) { 115 | // Splice the last two elements which are 116 | // -2: The state we are restoring 117 | // -1: The current state 118 | const [restore] = this._stateHistory.splice(length - 2); 119 | this._itemHistory.pop(); 120 | this.value = ""; 121 | this.update(restore); 122 | this.show(); 123 | } 124 | } 125 | 126 | private handleDidTriggerButton(button: QuickInputButton) { 127 | switch (button) { 128 | case WhichKeyMenu.UndoKeyButton: 129 | this.handleDidUndoKey(); 130 | break; 131 | case WhichKeyMenu.SearchBindingButton: 132 | this.handleDidSearchBindings(); 133 | break; 134 | } 135 | } 136 | 137 | protected override async handleAccept( 138 | item: BindingItem 139 | ): Promise { 140 | this._itemHistory.push(item); 141 | this._statusBar.hide(); 142 | 143 | const result = evalBindingCondition(item, this.condition); 144 | if (!result) { 145 | this._statusBar.setErrorMessage("No condition matched"); 146 | return undefined; 147 | } 148 | 149 | if (result.commands || result.command) { 150 | await this.hide(); 151 | const { commands, args } = toCommands(result); 152 | await executeCommands(commands, args); 153 | this._repeater?.record(this._itemHistory); 154 | } 155 | 156 | if (result.bindings) { 157 | this._statusBar.setPlainMessage( 158 | this.toHistoricalKeysString() + "-", 159 | 0 160 | ); 161 | const items = result.bindings; 162 | return { 163 | items, 164 | title: item.name, 165 | delay: this.delay, 166 | showMenu: true, 167 | buttons: WhichKeyMenu.NonRootMenuButtons, 168 | }; 169 | } else { 170 | return undefined; 171 | } 172 | } 173 | 174 | protected override async handleMismatch( 175 | key: string 176 | ): Promise { 177 | const keyCombo = this.toHistoricalKeysString(key); 178 | this._statusBar.setErrorMessage(`${keyCombo} is undefined`); 179 | return undefined; 180 | } 181 | 182 | protected override handleRender( 183 | items: BindingItem[] 184 | ): BaseWhichKeyQuickPickItem[] { 185 | items = items.filter((i) => i.display !== DisplayOption.Hidden); 186 | const max = items.reduce( 187 | (acc, val) => (acc > val.key.length ? acc : val.key.length), 188 | 0 189 | ); 190 | 191 | return items.map((i) => { 192 | const icon = 193 | this.showIcons && i.icon && i.icon.length > 0 194 | ? `$(${i.icon}) ` 195 | : ""; 196 | const label = this.useFullWidthCharacters 197 | ? toFullWidthSpecializedKey(i.key) + 198 | toFullWidthKey(" ".repeat(max - i.key.length)) 199 | : toSpecializedKey(i.key); 200 | return { 201 | label, 202 | description: `\t${icon}${i.name}`, 203 | item: i, 204 | }; 205 | }); 206 | } 207 | 208 | private toHistoricalKeysString(currentKey?: string): string { 209 | let keyCombo = this._itemHistory.map((i) => i.key); 210 | if (currentKey) { 211 | keyCombo = keyCombo.concat(currentKey); 212 | } 213 | return keyCombo.map(toSpecializedKey).join(" "); 214 | } 215 | 216 | override update(state: BaseWhichKeyMenuState): void { 217 | this._stateHistory.push(state); 218 | super.update(state); 219 | } 220 | 221 | override dispose() { 222 | this._statusBar.hidePlain(); 223 | for (const d of this.__disposables) { 224 | d.dispose(); 225 | } 226 | 227 | super.dispose(); 228 | } 229 | } 230 | 231 | export function showWhichKeyMenu( 232 | statusBar: StatusBar, 233 | cmdRelay: CommandRelay, 234 | repeater: WhichKeyRepeater | undefined, 235 | config: WhichKeyMenuConfig 236 | ) { 237 | const menu = new WhichKeyMenu(statusBar, cmdRelay, repeater); 238 | menu.delay = config.delay; 239 | menu.showIcons = config.showIcons; 240 | menu.showButtons = config.showButtons; 241 | menu.useFullWidthCharacters = config.useFullWidthCharacters; 242 | menu.update({ 243 | items: config.bindings, 244 | title: config.title, 245 | delay: config.delay, 246 | showMenu: true, 247 | buttons: [WhichKeyMenu.SearchBindingButton], 248 | }); 249 | 250 | // Explicitly not wait for the whole menu to resolve 251 | // to fix the issue where executing show command which can freeze vim instead of waiting on menu. 252 | // In addition, show command waits until we call menu show to allow chaining command of show and triggerKey. 253 | // Specifically, when triggerKey called before shown is done. The value will be set before shown, which causes the 254 | // value to be selected. 255 | (async () => { 256 | try { 257 | await Promise.all([ 258 | new Promise((resolve, reject) => { 259 | menu.onDidResolve = resolve; 260 | menu.onDidReject = reject; 261 | menu.show(); 262 | }), 263 | setContext(ContextKey.Active, true), 264 | ]); 265 | } catch (e: any) { 266 | window.showErrorMessage(e.toString()); 267 | } finally { 268 | await Promise.all([ 269 | setContext(ContextKey.Active, false), 270 | // We set visible to true before QuickPick is shown (because vscode doesn't provide onShown API) 271 | // Visible can be stuck in true if the menu is disposed before it's shown (e.g. 272 | // calling show without waiting and triggerKey command in sequence) 273 | // Therefore, we are cleaning up here to make sure it is not stuck. 274 | setContext(ContextKey.Visible, false), 275 | ]); 276 | } 277 | })(); 278 | } 279 | -------------------------------------------------------------------------------- /src/statusBar.ts: -------------------------------------------------------------------------------- 1 | import { Disposable, StatusBarItem, ThemeColor, window } from "vscode"; 2 | 3 | export class StatusBar implements Disposable { 4 | static DEFAULT_TIMEOUT = 3000; 5 | static ERROR_FG_COLOR = new ThemeColor("statusBarItem.errorForeground"); 6 | static ERROR_BG_COLOR = new ThemeColor("statusBarItem.errorBackground"); 7 | 8 | private _timeout: number; 9 | private _isError: boolean; 10 | private _item: StatusBarItem; 11 | private _timerId?: NodeJS.Timer; 12 | 13 | constructor() { 14 | this._isError = false; 15 | this._item = window.createStatusBarItem(); 16 | this._timeout = StatusBar.DEFAULT_TIMEOUT; 17 | } 18 | 19 | /** 20 | * The timeout in milliseconds of the message display. Set it to non-positive number to display message until this instance is disposed. 21 | */ 22 | get timeout(): number { 23 | return this._timeout; 24 | } 25 | 26 | /** 27 | * The timeout in milliseconds of the message display. Set it to non-positive number to display message until this instance is disposed. 28 | */ 29 | set timeout(ms: number) { 30 | this._timeout = ms; 31 | } 32 | 33 | private _setMessage( 34 | text: string, 35 | isError: boolean, 36 | timeout?: number 37 | ): void { 38 | this.clearTimeout(); 39 | const fgColor = isError ? StatusBar.ERROR_FG_COLOR : undefined; 40 | const bgColor = isError ? StatusBar.ERROR_BG_COLOR : undefined; 41 | this._item.color = fgColor; 42 | this._item.backgroundColor = bgColor; 43 | this._item.text = text; 44 | this._isError = isError; 45 | this.show(timeout); 46 | } 47 | 48 | private clearTimeout(): void { 49 | if (this._timerId) { 50 | clearTimeout(this._timerId); 51 | this._timerId = undefined; 52 | } 53 | } 54 | 55 | /** 56 | * Set and show plain message with a specified timeout for this message. 57 | * @param text The message to display on the status bar. 58 | * @param timeout An optional timeout in ms for this message only. 59 | */ 60 | setPlainMessage(text: string, timeout?: number): void { 61 | this._setMessage(text, false, timeout); 62 | } 63 | 64 | /** 65 | * Set and show error message with a specified timeout for this message. 66 | * @param text The message to display on the status bar. 67 | * @param timeout An optional timeout in ms for this message only. 68 | */ 69 | setErrorMessage(text: string, timeout?: number): void { 70 | this._setMessage(text, true, timeout); 71 | } 72 | 73 | /** 74 | * Show the message. 75 | * @param timeout An optional timeout override for this show call. 76 | */ 77 | show(timeout?: number): void { 78 | const ms = timeout ?? this._timeout; 79 | this._item.show(); 80 | if (ms > 0) { 81 | this._timerId = setTimeout(this.hide.bind(this), ms); 82 | } 83 | } 84 | 85 | /** 86 | * Hide all types of message. 87 | */ 88 | hide(): void { 89 | this.clearTimeout(); 90 | this._item.hide(); 91 | } 92 | 93 | /** 94 | * Hide only the plain message. 95 | */ 96 | hidePlain(): void { 97 | if (!this._isError) { 98 | this.hide(); 99 | } 100 | } 101 | 102 | /** 103 | * Hide only the error message. 104 | */ 105 | hideError(): void { 106 | if (this._isError) { 107 | this.hide(); 108 | } 109 | } 110 | 111 | dispose(): void { 112 | this.clearTimeout(); 113 | this._item.dispose(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/test/runTest-node.ts: -------------------------------------------------------------------------------- 1 | import { runTests } from "@vscode/test-electron"; 2 | import * as path from "path"; 3 | 4 | async function main() { 5 | try { 6 | // The folder containing the Extension Manifest package.json 7 | // Passed to `--extensionDevelopmentPath` 8 | const extensionDevelopmentPath = path.resolve(__dirname, "../../"); 9 | 10 | // The path to test runner 11 | // Passed to --extensionTestsPath 12 | const extensionTestsPath = path.resolve( 13 | __dirname, 14 | "./suite/index-node" 15 | ); 16 | 17 | // Download VS Code, unzip it and run the integration test 18 | await runTests({ 19 | version: "stable", 20 | extensionDevelopmentPath, 21 | extensionTestsPath, 22 | }); 23 | } catch (err) { 24 | console.error(err); 25 | console.error("Failed to run tests"); 26 | process.exit(1); 27 | } 28 | } 29 | 30 | main(); 31 | -------------------------------------------------------------------------------- /src/test/runTest-web.ts: -------------------------------------------------------------------------------- 1 | import { runTests } from "@vscode/test-web"; 2 | import * as path from "path"; 3 | 4 | async function main() { 5 | try { 6 | // The folder containing the Extension Manifest package.json 7 | // Passed to `--extensionDevelopmentPath` 8 | const extensionDevelopmentPath = path.resolve(__dirname, "../../"); 9 | 10 | // The path to test runner 11 | // Passed to --extensionTestsPath 12 | const extensionTestsPath = path.resolve(__dirname, "./suite/index-web"); 13 | 14 | // Start a web server that serves VSCode in a browser, run the tests 15 | await runTests({ 16 | browserType: "chromium", 17 | version: "stable", 18 | extensionDevelopmentPath, 19 | extensionTestsPath, 20 | }); 21 | } catch (err) { 22 | console.error(err); 23 | console.error("Failed to run tests"); 24 | process.exit(1); 25 | } 26 | } 27 | 28 | main(); 29 | -------------------------------------------------------------------------------- /src/test/suite/dispatchQueue.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { DispatchQueue } from "../../dispatchQueue"; 3 | import { wait } from "./testUtils"; 4 | 5 | suite(DispatchQueue.name, function () { 6 | test("can push and receive", async function () { 7 | const expected = 1; 8 | const actual = await new Promise((r) => { 9 | const queue = new DispatchQueue(async (item: number) => { 10 | if (queue.length === 0) { 11 | r(item); 12 | } 13 | }); 14 | queue.push(expected); 15 | }); 16 | 17 | assert.strictEqual(actual, expected); 18 | }); 19 | 20 | test("can queue up while one is processing", async function () { 21 | const actual: number[] = []; 22 | const lengths: number[] = []; 23 | await new Promise((r) => { 24 | const queue = new DispatchQueue(async (item: number) => { 25 | await wait(100); 26 | lengths.push(queue.length); 27 | actual.push(item); 28 | if (item === 3) { 29 | r(); 30 | } 31 | }); 32 | queue.push(1); 33 | queue.push(2); 34 | queue.push(3); 35 | }); 36 | 37 | assert.deepStrictEqual(actual, [1, 2, 3]); 38 | assert.deepStrictEqual(lengths, [2, 1, 0]); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import * as vscode from "vscode"; 3 | import { extensionId } from "../../constants"; 4 | 5 | suite("Extension Test Suite", () => { 6 | vscode.window.showInformationMessage("Start all tests."); 7 | 8 | test("whichkey can be activated", async function () { 9 | this.timeout(1 * 60 * 1000); 10 | const extension = vscode.extensions.getExtension(extensionId); 11 | if (extension) { 12 | await extension.activate(); 13 | assert.ok(extension.isActive); 14 | } else { 15 | assert.fail("Extension is not available"); 16 | } 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/test/suite/index-node.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as Mocha from "mocha"; 3 | import * as glob from "glob"; 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: "tdd", 9 | color: true, 10 | }); 11 | 12 | const testsRoot = path.resolve(__dirname, ".."); 13 | 14 | return new Promise((c, e) => { 15 | glob("**/**.test.js", { cwd: testsRoot }, (err, files) => { 16 | if (err) { 17 | return e(err); 18 | } 19 | 20 | // Add files to the test suite 21 | files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f))); 22 | 23 | try { 24 | // Run the mocha test 25 | mocha.run((failures) => { 26 | if (failures > 0) { 27 | e(new Error(`${failures} tests failed.`)); 28 | } else { 29 | c(); 30 | } 31 | }); 32 | } catch (err) { 33 | console.error(err); 34 | e(err); 35 | } 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/test/suite/index-web.ts: -------------------------------------------------------------------------------- 1 | require("mocha/mocha"); // import the mocha web build 2 | 3 | export function run(): Promise { 4 | return new Promise((c, e) => { 5 | mocha.setup({ 6 | ui: "tdd", 7 | reporter: undefined, 8 | }); 9 | 10 | // bundles all files in the current directory matching `*.test` 11 | const importAll = (r: __WebpackModuleApi.RequireContext) => 12 | r.keys().forEach(r); 13 | importAll(require.context(".", true, /\.test$/)); 14 | 15 | try { 16 | // Run the mocha test 17 | mocha.run((failures) => { 18 | if (failures > 0) { 19 | e(new Error(`${failures} tests failed.`)); 20 | } else { 21 | c(); 22 | } 23 | }); 24 | } catch (err) { 25 | console.error(err); 26 | e(err); 27 | } 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /src/test/suite/menu/whichkeyMenu.test.ts: -------------------------------------------------------------------------------- 1 | import { commands, Disposable } from "vscode"; 2 | import { CommandRelay } from "../../../commandRelay"; 3 | import { ActionType, DisplayOption } from "../../../config/bindingItem"; 4 | import { WhichKeyMenuConfig } from "../../../config/menuConfig"; 5 | import { showWhichKeyMenu } from "../../../menu/whichKeyMenu"; 6 | import { StatusBar } from "../../../statusBar"; 7 | 8 | suite("WhichKeyMenu", function () { 9 | let disposables: Disposable[] = []; 10 | 11 | this.beforeEach(() => { 12 | disposables = []; 13 | }); 14 | 15 | this.afterEach(() => { 16 | for (const d of disposables) { 17 | d.dispose(); 18 | } 19 | }); 20 | 21 | test("can trigger keys right after show executes", function (done) { 22 | const statusBar = new StatusBar(); 23 | const cmdRelay = new CommandRelay(); 24 | const config: WhichKeyMenuConfig = { 25 | delay: 0, 26 | showIcons: false, 27 | showButtons: false, 28 | useFullWidthCharacters: false, 29 | title: "Test", 30 | bindings: [ 31 | { 32 | key: "m", 33 | name: "+Major", 34 | type: ActionType.Bindings, 35 | bindings: [ 36 | { 37 | key: "x", 38 | name: "test command", 39 | type: ActionType.Command, 40 | command: "whichkey.testCommand", 41 | }, 42 | ], 43 | }, 44 | ], 45 | }; 46 | disposables = [ 47 | statusBar, 48 | cmdRelay, 49 | commands.registerCommand("whichkey.testCommand", () => { 50 | done(); 51 | }), 52 | ]; 53 | 54 | showWhichKeyMenu(statusBar, cmdRelay, undefined, config); 55 | cmdRelay.triggerKey("m"); 56 | cmdRelay.triggerKey("x"); 57 | }); 58 | 59 | test("can hide item with `display: hidden`", function (done) { 60 | const statusBar = new StatusBar(); 61 | const cmdRelay = new CommandRelay(); 62 | const config: WhichKeyMenuConfig = { 63 | delay: 0, 64 | showIcons: false, 65 | showButtons: false, 66 | useFullWidthCharacters: false, 67 | title: "Test", 68 | bindings: [ 69 | { 70 | key: "a", 71 | name: "Should be hidden", 72 | type: ActionType.Command, 73 | display: DisplayOption.Hidden, 74 | command: "whichkey.hiddenCommand", 75 | }, 76 | { 77 | key: "x", 78 | name: "test command", 79 | type: ActionType.Command, 80 | command: "whichkey.testCommand", 81 | }, 82 | ], 83 | }; 84 | disposables = [ 85 | statusBar, 86 | cmdRelay, 87 | commands.registerCommand("whichkey.testCommand", () => { 88 | done(); 89 | }), 90 | commands.registerCommand("whichkey.hiddenCommand", () => { 91 | done("The item should be hidden"); 92 | }), 93 | ]; 94 | 95 | showWhichKeyMenu(statusBar, cmdRelay, undefined, config); 96 | // Wait until the UI is shown 97 | setTimeout(() => { 98 | commands.executeCommand( 99 | "workbench.action.acceptSelectedQuickOpenItem" 100 | ); 101 | }, 100); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/test/suite/testUtils.ts: -------------------------------------------------------------------------------- 1 | export function wait(ms: number) { 2 | return new Promise((r) => { 3 | setTimeout(() => { 4 | r(); 5 | }, ms); 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { commands, workspace } from "vscode"; 2 | import { CharCode } from "./charCode"; 3 | 4 | export const nameof = (name: keyof T) => name; 5 | 6 | export function setContext(key: string, value: any): Thenable { 7 | return commands.executeCommand("setContext", key, value); 8 | } 9 | 10 | export function executeCommand(cmd: string, args: any): Thenable { 11 | if (Array.isArray(args)) { 12 | const arr = args as any[]; 13 | return commands.executeCommand(cmd, ...arr); 14 | } else if (args) { 15 | // undefined from the object chainning/indexing or 16 | // null from the json deserialization 17 | return commands.executeCommand(cmd, args); 18 | } else { 19 | return commands.executeCommand(cmd); 20 | } 21 | } 22 | 23 | export async function executeCommands( 24 | cmds: string[], 25 | args: any 26 | ): Promise { 27 | for (let i = 0; i < cmds.length; i++) { 28 | const cmd = cmds[i]; 29 | const arg = args?.[i]; 30 | await executeCommand(cmd, arg); 31 | } 32 | } 33 | 34 | /** 35 | * Get workspace configuration 36 | * @param section The configuration name. 37 | */ 38 | export function getConfig(section: string): T | undefined { 39 | // Get the minimal scope 40 | let filterSection: string | undefined = undefined; 41 | let lastSection: string = section; 42 | const idx = section.lastIndexOf("."); 43 | if (idx !== -1) { 44 | filterSection = section.substring(0, idx); 45 | lastSection = section.substring(idx + 1); 46 | } 47 | 48 | return workspace.getConfiguration(filterSection).get(lastSection); 49 | } 50 | 51 | export function pipe(...fns: Array<(arg: T) => T>) { 52 | return (x: T) => fns.reduce((v, f) => f(v), x); 53 | } 54 | 55 | // https://en.wikipedia.org/wiki/Halfwidth_and_Fullwidth_Forms_(Unicode_block) 56 | export function toFullWidthKey(s: string): string { 57 | let key = ""; 58 | for (const symbol of s) { 59 | const codePoint = symbol.codePointAt(0); 60 | if ( 61 | codePoint && 62 | codePoint >= CharCode.Exclamation && 63 | codePoint <= CharCode.Tide 64 | ) { 65 | // Only replace single character string to full width 66 | // ASCII character into full width characters 67 | key += String.fromCodePoint(codePoint + 65248); 68 | } else if (codePoint === CharCode.Space) { 69 | // Full width space character 70 | key += "\u3000"; 71 | } else { 72 | key += symbol; 73 | } 74 | } 75 | 76 | return key; 77 | } 78 | 79 | export function toSpecializedKey(s: string): string { 80 | let key = ""; 81 | for (const symbol of s) { 82 | const codePoint = symbol.codePointAt(0); 83 | if (codePoint === CharCode.Space) { 84 | // Space 85 | key += "␣"; 86 | } else if (codePoint === CharCode.Tab) { 87 | // tab 88 | key += "↹"; 89 | } else { 90 | key += symbol; 91 | } 92 | } 93 | 94 | return key; 95 | } 96 | 97 | export const toFullWidthSpecializedKey = pipe(toSpecializedKey, toFullWidthKey); 98 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | export class Version { 2 | private _major: number; 3 | private _minor: number; 4 | private _patch: number; 5 | constructor(major: number, minor: number, patch: number) { 6 | this._major = major; 7 | this._minor = minor; 8 | this._patch = patch; 9 | } 10 | 11 | get major() { 12 | return this._major; 13 | } 14 | 15 | get minor() { 16 | return this._minor; 17 | } 18 | 19 | get patch() { 20 | return this._patch; 21 | } 22 | 23 | compare(other: Version) { 24 | if (this._major > other._major) { 25 | return ComparisonResult.Newer; 26 | } 27 | if (this._major < other._major) { 28 | return ComparisonResult.Older; 29 | } 30 | 31 | if (this._minor > other._minor) { 32 | return ComparisonResult.Newer; 33 | } 34 | if (this._minor < other._minor) { 35 | return ComparisonResult.Older; 36 | } 37 | 38 | if (this._patch > other._patch) { 39 | return ComparisonResult.Newer; 40 | } 41 | if (this._patch < other._patch) { 42 | return ComparisonResult.Older; 43 | } 44 | 45 | return ComparisonResult.Same; 46 | } 47 | 48 | static compare(v1: Version | string, v2: Version | string) { 49 | if (typeof v1 === "string") { 50 | v1 = this.parse(v1); 51 | } 52 | if (typeof v2 === "string") { 53 | v2 = this.parse(v2); 54 | } 55 | 56 | return v1.compare(v2); 57 | } 58 | 59 | static parse(v: string) { 60 | const [major, minor, patch] = v.split(".").map((v) => parseInt(v, 10)); 61 | return new Version(major, minor, patch); 62 | } 63 | } 64 | 65 | export const enum ComparisonResult { 66 | Older = -1, 67 | Same = 0, 68 | Newer = 1, 69 | } 70 | -------------------------------------------------------------------------------- /src/whichKeyCommand.ts: -------------------------------------------------------------------------------- 1 | import { Disposable, workspace } from "vscode"; 2 | import { getSortComparer } from "./bindingComparer"; 3 | import { CommandRelay } from "./commandRelay"; 4 | import { 5 | ActionType, 6 | BindingItem, 7 | OverrideBindingItem, 8 | toCommands, 9 | TransientBindingItem, 10 | } from "./config/bindingItem"; 11 | import { isConditionKeyEqual } from "./config/condition"; 12 | import { WhichKeyConfig } from "./config/whichKeyConfig"; 13 | import { Commands, Configs, SortOrder } from "./constants"; 14 | import { showWhichKeyMenu } from "./menu/whichKeyMenu"; 15 | import { StatusBar } from "./statusBar"; 16 | import { getConfig } from "./utils"; 17 | import { WhichKeyRepeater } from "./whichKeyRepeater"; 18 | 19 | function indexOfKey( 20 | bindingItems: BindingItem[] | undefined, 21 | key: string, 22 | isCondition = false 23 | ): number { 24 | if (isCondition) { 25 | return ( 26 | bindingItems?.findIndex((i) => isConditionKeyEqual(i.key, key)) ?? 27 | -1 28 | ); 29 | } else { 30 | return bindingItems?.findIndex((i) => i.key === key) ?? -1; 31 | } 32 | } 33 | 34 | function findBindings( 35 | items: BindingItem[], 36 | keys: string[] 37 | ): { 38 | bindingItems: BindingItem[] | undefined; 39 | isCondition: boolean; 40 | } { 41 | // Traverse to the last level 42 | let bindingItems: BindingItem[] | undefined = items; 43 | let isCondition = false; 44 | for (let i = 0; i < keys.length - 1; i++) { 45 | const key = keys[i]; 46 | const keyIndex = indexOfKey(bindingItems, key, isCondition); 47 | if (keyIndex === -1) { 48 | console.warn(`Key ${key} of ${keys.toString()} not found`); 49 | break; 50 | } 51 | 52 | isCondition = bindingItems?.[keyIndex].type === ActionType.Conditional; 53 | bindingItems = bindingItems?.[keyIndex]?.bindings; 54 | } 55 | 56 | return { bindingItems, isCondition }; 57 | } 58 | 59 | function convertOverride(key: string, o: OverrideBindingItem): BindingItem { 60 | if (o.name !== undefined && o.type !== undefined) { 61 | return { 62 | key: key, 63 | name: o.name, 64 | icon: o.icon, 65 | display: o.display, 66 | type: o.type, 67 | command: o.command, 68 | commands: o.commands, 69 | args: o.args, 70 | bindings: o.bindings, 71 | }; 72 | } else { 73 | throw new Error("name or type of the override is undefined"); 74 | } 75 | } 76 | 77 | function overrideBindingItems( 78 | items: BindingItem[], 79 | overrides: OverrideBindingItem[] 80 | ): void { 81 | for (const o of overrides) { 82 | try { 83 | const keys = 84 | typeof o.keys === "string" ? o.keys.split(".") : o.keys; 85 | const { bindingItems, isCondition } = findBindings(items, keys); 86 | 87 | if (bindingItems !== undefined) { 88 | const key = keys[keys.length - 1]; // last Key 89 | const index = indexOfKey(bindingItems, key, isCondition); 90 | 91 | if (o.position === undefined) { 92 | const newItem = convertOverride(key, o); 93 | if (index !== -1) { 94 | // replace the current item 95 | bindingItems.splice(index, 1, newItem); 96 | } else { 97 | // append if there isn't an existing binding 98 | bindingItems.push(newItem); 99 | } 100 | } else { 101 | if (o.position < 0) { 102 | // negative position, attempt to remove 103 | if (index !== -1) { 104 | bindingItems.splice(index, 1); 105 | } 106 | } else { 107 | // Remove and replace 108 | if (index !== -1) { 109 | bindingItems.splice(index, 1); 110 | } 111 | const newItem = convertOverride(key, o); 112 | bindingItems.splice(o.position, 0, newItem); 113 | } 114 | } 115 | } 116 | } catch (e) { 117 | console.error(e); 118 | } 119 | } 120 | } 121 | 122 | function sortBindingsItems( 123 | items: BindingItem[] | undefined, 124 | comparer: ((a: BindingItem, b: BindingItem) => number) | undefined 125 | ): void { 126 | if (!items || !comparer) { 127 | return; 128 | } 129 | 130 | items.sort(comparer); 131 | for (const item of items) { 132 | sortBindingsItems(item.bindings, comparer); 133 | } 134 | } 135 | 136 | function convertToTransientBinding(item: BindingItem): TransientBindingItem[] { 137 | const transientBindings: TransientBindingItem[] = []; 138 | if (item.bindings) { 139 | for (const b of item.bindings) { 140 | if ( 141 | b.type === ActionType.Command || 142 | b.type === ActionType.Commands 143 | ) { 144 | transientBindings.push({ 145 | key: b.key, 146 | name: b.name, 147 | icon: b.icon, 148 | display: b.display, 149 | ...toCommands(b), 150 | }); 151 | } else if (b.type === ActionType.Bindings) { 152 | transientBindings.push({ 153 | key: b.key, 154 | name: b.name, 155 | icon: b.icon, 156 | display: b.display, 157 | command: Commands.Show, 158 | args: b.bindings, 159 | exit: true, 160 | }); 161 | } else if (b.type === ActionType.Transient) { 162 | transientBindings.push({ 163 | key: b.key, 164 | name: b.name, 165 | icon: b.icon, 166 | display: b.display, 167 | command: Commands.ShowTransient, 168 | args: { 169 | title: item.name, 170 | bindings: convertToTransientBinding(item), 171 | }, 172 | exit: true, 173 | }); 174 | } else { 175 | console.error( 176 | `Type ${b.type} is not supported in convertToTransientBinding` 177 | ); 178 | } 179 | } 180 | } 181 | return transientBindings; 182 | } 183 | 184 | function migrateTransient(item: BindingItem): BindingItem { 185 | if (item.type === ActionType.Transient) { 186 | const { commands, args } = toCommands(item); 187 | commands.push(Commands.ShowTransient); 188 | args[commands.length - 1] = { 189 | title: item.name, 190 | bindings: convertToTransientBinding(item), 191 | }; 192 | 193 | return { 194 | key: item.key, 195 | name: item.name, 196 | icon: item.icon, 197 | display: item.display, 198 | type: ActionType.Commands, 199 | commands, 200 | args, 201 | }; 202 | } 203 | return item; 204 | } 205 | 206 | function migrateBindings(items: BindingItem[]): BindingItem[] { 207 | const migrated: BindingItem[] = []; 208 | for (let i of items) { 209 | i = migrateTransient(i); 210 | if (i.bindings) { 211 | i.bindings = migrateBindings(i.bindings); 212 | } 213 | migrated.push(i); 214 | } 215 | return migrated; 216 | } 217 | 218 | function getCanonicalConfig(c: WhichKeyConfig): BindingItem[] { 219 | const bindings = getConfig(c.bindings) ?? []; 220 | if (c.overrides) { 221 | const overrides = getConfig(c.overrides) ?? []; 222 | overrideBindingItems(bindings, overrides); 223 | } 224 | 225 | const sortOrder = getConfig(Configs.SortOrder) ?? SortOrder.None; 226 | const sortComparer = getSortComparer(sortOrder); 227 | sortBindingsItems(bindings, sortComparer); 228 | 229 | return migrateBindings(bindings); 230 | } 231 | export default class WhichKeyCommand { 232 | private statusBar: StatusBar; 233 | private cmdRelay: CommandRelay; 234 | private repeater: WhichKeyRepeater; 235 | private bindingItems?: BindingItem[]; 236 | private config?: WhichKeyConfig; 237 | private onConfigChangeListener?: Disposable; 238 | constructor(statusBar: StatusBar, cmdRelay: CommandRelay) { 239 | this.statusBar = statusBar; 240 | this.cmdRelay = cmdRelay; 241 | this.repeater = new WhichKeyRepeater(statusBar, cmdRelay); 242 | } 243 | 244 | register(config: WhichKeyConfig): void { 245 | this.unregister(); 246 | this.config = config; 247 | 248 | this.bindingItems = getCanonicalConfig(config); 249 | this.onConfigChangeListener = workspace.onDidChangeConfiguration( 250 | (e) => { 251 | if ( 252 | e.affectsConfiguration(Configs.SortOrder) || 253 | e.affectsConfiguration(config.bindings) || 254 | (config.overrides && 255 | e.affectsConfiguration(config.overrides)) 256 | ) { 257 | this.register(config); 258 | } 259 | }, 260 | this 261 | ); 262 | } 263 | 264 | unregister(): void { 265 | this.onConfigChangeListener?.dispose(); 266 | this.repeater.clear(); 267 | } 268 | 269 | show(): void { 270 | const delay = getConfig(Configs.Delay) ?? 0; 271 | const showIcons = getConfig(Configs.ShowIcons) ?? true; 272 | const showButtons = getConfig(Configs.ShowButtons) ?? true; 273 | const useFullWidthCharacters = 274 | getConfig(Configs.UseFullWidthCharacters) ?? false; 275 | const config = { 276 | bindings: this.bindingItems!, 277 | delay, 278 | showIcons, 279 | showButtons, 280 | useFullWidthCharacters, 281 | title: this.config?.title, 282 | }; 283 | showWhichKeyMenu(this.statusBar, this.cmdRelay, this.repeater, config); 284 | } 285 | 286 | showPreviousActions(): Promise { 287 | return this.repeater.show(); 288 | } 289 | 290 | repeatLastAction(): Promise { 291 | return this.repeater.repeatLastAction(); 292 | } 293 | 294 | static show( 295 | bindings: BindingItem[], 296 | statusBar: StatusBar, 297 | cmdRelay: CommandRelay 298 | ): void { 299 | const delay = getConfig(Configs.Delay) ?? 0; 300 | const showIcons = getConfig(Configs.ShowIcons) ?? true; 301 | const showButtons = getConfig(Configs.ShowButtons) ?? true; 302 | const useFullWidthCharacters = 303 | getConfig(Configs.UseFullWidthCharacters) ?? false; 304 | const config = { 305 | bindings, 306 | delay, 307 | showIcons, 308 | showButtons, 309 | useFullWidthCharacters, 310 | }; 311 | showWhichKeyMenu(statusBar, cmdRelay, undefined, config); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/whichKeyRegistry.ts: -------------------------------------------------------------------------------- 1 | import { Disposable } from "vscode"; 2 | import { toBindingItem } from "./config/bindingItem"; 3 | import { CommandRelay } from "./commandRelay"; 4 | import { StatusBar } from "./statusBar"; 5 | import WhichKeyCommand from "./whichKeyCommand"; 6 | import { 7 | defaultWhichKeyConfig, 8 | toWhichKeyConfig, 9 | } from "./config/whichKeyConfig"; 10 | 11 | function notEmpty(value: TValue | null | undefined): value is TValue { 12 | return value !== null && value !== undefined; 13 | } 14 | 15 | export class WhichKeyRegistry implements Disposable { 16 | private registry: Record; 17 | private statusBar: StatusBar; 18 | private cmdRelay: CommandRelay; 19 | 20 | constructor(statusBar: StatusBar, cmdRelay: CommandRelay) { 21 | this.statusBar = statusBar; 22 | this.cmdRelay = cmdRelay; 23 | this.registry = {}; 24 | } 25 | 26 | register(obj: any): boolean { 27 | const config = toWhichKeyConfig(obj); 28 | if (config) { 29 | const key = config.bindings; 30 | if (!this.has(key)) { 31 | this.registry[key] = new WhichKeyCommand( 32 | this.statusBar, 33 | this.cmdRelay 34 | ); 35 | } 36 | 37 | this.registry[key].register(config); 38 | return true; 39 | } else { 40 | console.warn("Incorrect which-key config format."); 41 | return false; 42 | } 43 | } 44 | 45 | has(section: string): boolean { 46 | return section in this.registry; 47 | } 48 | 49 | show(args: any): void { 50 | if (typeof args === "string") { 51 | this.registry[args].show(); 52 | } else if (Array.isArray(args) && args.length > 0) { 53 | // Vim call command with an array with length of 0 54 | const bindings = args.map(toBindingItem).filter(notEmpty); 55 | WhichKeyCommand.show(bindings, this.statusBar, this.cmdRelay); 56 | } else { 57 | const key = defaultWhichKeyConfig.bindings; 58 | if (!this.has(key)) { 59 | this.register(defaultWhichKeyConfig); 60 | } 61 | this.registry[key].show(); 62 | } 63 | } 64 | 65 | repeatRecent(args: any): Promise { 66 | return this.getRegister(args).showPreviousActions(); 67 | } 68 | 69 | repeatMostRecent(args: any): Promise { 70 | return this.getRegister(args).repeatLastAction(); 71 | } 72 | 73 | private getRegister(args: any): WhichKeyCommand { 74 | if (typeof args === "string") { 75 | return this.registry[args]; 76 | } else { 77 | const key = defaultWhichKeyConfig.bindings; 78 | if (!this.has(key)) { 79 | this.register(defaultWhichKeyConfig); 80 | } 81 | return this.registry[key]; 82 | } 83 | } 84 | 85 | unregister(section: string): void { 86 | if (this.has(section)) { 87 | this.registry[section].unregister(); 88 | } 89 | } 90 | 91 | dispose(): void { 92 | for (const key of Object.keys(this.register)) { 93 | this.registry[key].unregister(); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/whichKeyRepeater.ts: -------------------------------------------------------------------------------- 1 | import { CommandRelay } from "./commandRelay"; 2 | import { BindingItem, toCommands } from "./config/bindingItem"; 3 | import { Commands, Configs } from "./constants"; 4 | import { RepeaterMenuItem, showRepeaterMenu } from "./menu/repeaterMenu"; 5 | import { StatusBar } from "./statusBar"; 6 | import { executeCommands, getConfig } from "./utils"; 7 | 8 | function shouldIgnore(item: BindingItem): boolean { 9 | const cmds = toCommands(item).commands; 10 | const ignore = [Commands.RepeatRecent, Commands.RepeatMostRecent]; 11 | return cmds.findIndex(ignore.includes.bind(ignore)) >= 0; 12 | } 13 | 14 | class WhichKeyRepeaterEntry { 15 | private path: BindingItem[]; 16 | 17 | constructor(path: BindingItem[]) { 18 | this.path = [...path]; 19 | } 20 | 21 | get item(): BindingItem { 22 | return this.path[this.path.length - 1]; 23 | } 24 | 25 | get pathKey(): string { 26 | return this.path.map((p) => p.key).toString(); 27 | } 28 | 29 | get basePathNames(): string[] { 30 | return this.path.slice(0, -1).map((p) => p.name); 31 | } 32 | 33 | get shouldIgnore(): boolean { 34 | return this.path.length === 0 || shouldIgnore(this.item); 35 | } 36 | } 37 | 38 | export class WhichKeyRepeater { 39 | /** 40 | * The max number we can store is the number of single digit key we can press for whichkey menu. 41 | */ 42 | private static MaxSize = 9; 43 | /** 44 | * LRU cache; however our MAX_SIZE is so small that a list should suffice. 45 | */ 46 | private cache: WhichKeyRepeaterEntry[]; 47 | 48 | public constructor( 49 | private statusBar: StatusBar, 50 | private cmdRelay: CommandRelay 51 | ) { 52 | this.cache = []; 53 | } 54 | 55 | private get isEmpty(): boolean { 56 | return this.cache.length === 0; 57 | } 58 | 59 | private get length(): number { 60 | return this.cache.length; 61 | } 62 | 63 | public record(path: BindingItem[]): void { 64 | const newEntry = new WhichKeyRepeaterEntry(path); 65 | if (newEntry.shouldIgnore) { 66 | return; 67 | } 68 | 69 | const pathKey = newEntry.pathKey; 70 | const idx = this.cache.findIndex((c) => c.pathKey === pathKey); 71 | if (idx >= 0) { 72 | // If found, remove element 73 | this.cache.splice(idx, 1); 74 | } 75 | 76 | this.cache.unshift(newEntry); 77 | 78 | if (this.cache.length > WhichKeyRepeater.MaxSize) { 79 | this.cache.pop(); 80 | } 81 | } 82 | 83 | public async repeatLastAction(idx = 0): Promise { 84 | if (!this.isEmpty && idx >= 0 && idx < this.length) { 85 | const entry = this.cache.splice(idx, 1)[0]; 86 | const { commands, args } = toCommands(entry.item); 87 | await executeCommands(commands, args); 88 | this.cache.unshift(entry); 89 | } else { 90 | this.statusBar.setErrorMessage("No last action"); 91 | } 92 | } 93 | 94 | private repeatAction(pathKey: string): Promise { 95 | const idx = this.cache.findIndex((c) => c.pathKey === pathKey); 96 | return this.repeatLastAction(idx); 97 | } 98 | 99 | public show(): Promise { 100 | const config = { 101 | title: "Repeat previous actions", 102 | items: this.createMenuItems(), 103 | useFullWidthCharacters: 104 | getConfig(Configs.UseFullWidthCharacters) ?? false, 105 | }; 106 | return showRepeaterMenu(this.statusBar, this.cmdRelay, config); 107 | } 108 | 109 | public clear(): void { 110 | this.cache.length = 0; 111 | } 112 | 113 | private createMenuItems(): RepeaterMenuItem[] { 114 | return this.cache.map((entry, index) => { 115 | const key = index + 1; 116 | const menuItem: RepeaterMenuItem = { 117 | key: key.toString(), 118 | name: entry.item.name, 119 | basePathNames: entry.basePathNames, 120 | accept: this.repeatAction.bind(this, entry.pathKey), 121 | }; 122 | return menuItem; 123 | }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2018", 5 | "outDir": "out", 6 | "lib": ["es2018"], 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "strict": true, 10 | "alwaysStrict": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": true, 13 | "noImplicitOverride": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "forceConsistentCasingInFileNames": true 17 | }, 18 | "exclude": ["node_modules", ".vscode-test"] 19 | } 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const glob = require("glob"); 2 | const path = require("path"); 3 | const webpack = require("webpack"); 4 | 5 | const webConfig = /** @type WebpackConfig */ { 6 | context: __dirname, 7 | mode: "none", // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 8 | target: "webworker", // web extensions run in a webworker context 9 | entry: { 10 | "extension-web": "./src/extension.ts", // source of the web extension main file 11 | "test/suite/index-web": "./src/test/suite/index-web.ts", // source of the web extension test runner 12 | }, 13 | output: { 14 | filename: "[name].js", 15 | path: path.join(__dirname, "./dist"), 16 | libraryTarget: "commonjs", 17 | }, 18 | resolve: { 19 | mainFields: ["browser", "module", "main"], // look for `browser` entry point in imported node modules 20 | extensions: [".ts", ".js"], // support ts-files and js-files 21 | alias: { 22 | // provides alternate implementation for node module and source files 23 | }, 24 | fallback: { 25 | // Webpack 5 no longer polyfills Node.js core modules automatically. 26 | // see https://webpack.js.org/configuration/resolve/#resolvefallback 27 | // for the list of Node.js core module polyfills. 28 | assert: require.resolve("assert"), 29 | }, 30 | }, 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.ts$/, 35 | exclude: /node_modules/, 36 | use: [ 37 | { 38 | loader: "ts-loader", 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | plugins: [ 45 | new webpack.ProvidePlugin({ 46 | process: "process/browser", // provide a shim for the global `process` variable 47 | }), 48 | ], 49 | externals: { 50 | vscode: "commonjs vscode", // ignored because it doesn't exist 51 | }, 52 | performance: { 53 | hints: false, 54 | }, 55 | devtool: "nosources-source-map", // create a source map that points to the original source file 56 | }; 57 | const nodeConfig = /** @type WebpackConfig */ { 58 | context: __dirname, 59 | mode: "none", // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 60 | target: "node", // extensions run in a node context 61 | entry: { 62 | ...{ 63 | "extension-node": "./src/extension.ts", // source of the node extension main file 64 | "test/suite/index-node": "./src/test/suite/index-node.ts", // source of the node extension test runner 65 | "test/runTest-node": "./src/test/runTest-node", // used to start the VS Code test runner (@vscode/test-electron) 66 | "test/runTest-web": "./src/test/runTest-web", // used to start the VS Code test runner (@vscode/test-web) 67 | }, 68 | // create separate files for the test to be found by glob in code 69 | ...glob.sync("./src/test/suite/**/*.test.ts").reduce( 70 | (acc, curr) => ({ 71 | ...acc, 72 | // Remove `./src/` in front and `.ts` for key 73 | [curr.substring(6, curr.length - 3)]: curr, 74 | }), 75 | {} 76 | ), 77 | }, 78 | output: { 79 | filename: "[name].js", 80 | path: path.join(__dirname, "./dist"), 81 | libraryTarget: "commonjs", 82 | }, 83 | resolve: { 84 | mainFields: ["module", "main"], 85 | extensions: [".ts", ".js"], // support ts-files and js-files 86 | }, 87 | module: { 88 | rules: [ 89 | { 90 | test: /\.ts$/, 91 | exclude: /node_modules/, 92 | use: [ 93 | { 94 | loader: "ts-loader", 95 | }, 96 | ], 97 | }, 98 | ], 99 | }, 100 | externals: { 101 | vscode: "commonjs vscode", // ignored because it doesn't exist 102 | mocha: "commonjs mocha", // don't bundle 103 | "@vscode/test-electron": "commonjs @vscode/test-electron", // don't bundle 104 | "@vscode/test-web": "commonjs @vscode/test-web", // don't bundle 105 | }, 106 | performance: { 107 | hints: false, 108 | }, 109 | devtool: "nosources-source-map", // create a source map that points to the original source file 110 | }; 111 | 112 | module.exports = [webConfig, nodeConfig]; 113 | --------------------------------------------------------------------------------