├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── CONTRIBUTION.md ├── LICENSE.md ├── README.md ├── eslint.config.mjs ├── package.json ├── playground ├── .vscode │ └── settings.json ├── index.vue └── main.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── res └── icon.png ├── scripts └── docs.ts ├── src ├── context.ts ├── index.ts ├── log.ts ├── parsers │ ├── html.ts │ ├── index.ts │ └── javascript.ts ├── rules │ ├── bracket-pair.ts │ ├── dash.ts │ ├── html-attr.ts │ ├── html-element.ts │ ├── html-tag-pair.ts │ ├── index.ts │ ├── js-arrow-fn.ts │ ├── js-assign.ts │ ├── js-block.ts │ ├── js-colon.ts │ └── jsx-tag-pair.ts ├── trigger.ts ├── types.ts └── utils.ts ├── test └── index.test.ts ├── tsconfig.json └── tsdown.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [antfu] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Install pnpm 19 | uses: pnpm/action-setup@v4 20 | 21 | - name: Set node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | cache: pnpm 26 | 27 | - name: Setup 28 | run: npm i -g @antfu/ni 29 | 30 | - name: Install 31 | run: nci 32 | 33 | - name: Lint 34 | run: nr lint 35 | 36 | # typecheck: 37 | # runs-on: ubuntu-latest 38 | # steps: 39 | # - uses: actions/checkout@v2 40 | 41 | # - name: Install pnpm 42 | # uses: pnpm/action-setup@v2.2.1 43 | 44 | # - name: Set node 45 | # uses: actions/setup-node@v2 46 | # with: 47 | # node-version: 16.x 48 | # cache: pnpm 49 | 50 | # - name: Setup 51 | # run: npm i -g @antfu/ni 52 | 53 | # - name: Install 54 | # run: nci 55 | 56 | # - name: Typecheck 57 | # run: nr typecheck 58 | 59 | test: 60 | runs-on: ${{ matrix.os }} 61 | 62 | strategy: 63 | matrix: 64 | node: [lts/*] 65 | os: [ubuntu-latest] 66 | fail-fast: false 67 | 68 | steps: 69 | - uses: actions/checkout@v4 70 | 71 | - name: Install pnpm 72 | uses: pnpm/action-setup@v4 73 | 74 | - name: Set node version to ${{ matrix.node }} 75 | uses: actions/setup-node@v4 76 | with: 77 | node-version: ${{ matrix.node }} 78 | cache: pnpm 79 | 80 | - name: Setup 81 | run: npm i -g @antfu/ni 82 | 83 | - name: Install 84 | run: nci 85 | 86 | - name: Build 87 | run: nr build 88 | 89 | - name: Test 90 | run: nr test 91 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: lts/* 22 | 23 | - run: npx changelogithub 24 | env: 25 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .idea 4 | *.log 5 | *.tgz 6 | coverage 7 | dist 8 | lib-cov 9 | logs 10 | node_modules 11 | temp 12 | *.vsix 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | shell-emulator=true 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "amodio.tsl-problem-matcher" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "runtimeExecutable": "${execPath}", 9 | "args": [ 10 | "--extensionDevelopmentPath=${workspaceFolder}", 11 | "${workspaceFolder}/playground" 12 | ], 13 | "outFiles": [ 14 | "${workspaceFolder}/dist/**/*.js" 15 | ], 16 | "preLaunchTask": "npm: dev" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | // (remove this if your ESLint extension above v3.0.5) 4 | "eslint.experimental.useFlatConfig": true, 5 | 6 | // Disable the default formatter, use eslint instead 7 | "prettier.enable": false, 8 | "editor.formatOnSave": false, 9 | 10 | // Auto fix 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": "explicit", 13 | "source.organizeImports": "never" 14 | }, 15 | 16 | // Silent the stylistic rules in you IDE, but still auto fix them 17 | "eslint.rules.customizations": [ 18 | { "rule": "style/*", "severity": "off" }, 19 | { "rule": "format/*", "severity": "off" }, 20 | { "rule": "*-indent", "severity": "off" }, 21 | { "rule": "*-spacing", "severity": "off" }, 22 | { "rule": "*-spaces", "severity": "off" }, 23 | { "rule": "*-order", "severity": "off" }, 24 | { "rule": "*-dangle", "severity": "off" }, 25 | { "rule": "*-newline", "severity": "off" }, 26 | { "rule": "*quotes", "severity": "off" }, 27 | { "rule": "*semi", "severity": "off" } 28 | ], 29 | 30 | // Enable eslint for all supported languages 31 | "eslint.validate": [ 32 | "javascript", 33 | "javascriptreact", 34 | "typescript", 35 | "typescriptreact", 36 | "vue", 37 | "html", 38 | "markdown", 39 | "json", 40 | "jsonc", 41 | "yaml", 42 | "toml", 43 | "xml", 44 | "gql", 45 | "graphql", 46 | "astro", 47 | "css", 48 | "less", 49 | "scss", 50 | "pcss", 51 | "postcss" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /.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": "dev", 9 | "isBackground": true, 10 | "presentation": { 11 | "reveal": "never" 12 | }, 13 | "problemMatcher": [ 14 | { 15 | "base": "$ts-webpack-watch", 16 | "background": { 17 | "activeOnStart": true, 18 | "beginsPattern": "Build start", 19 | "endsPattern": "Build success" 20 | } 21 | } 22 | ], 23 | "group": "build" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /CONTRIBUTION.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | ## Steps 4 | 5 | 1. Fork and clone the repo. 6 | 2. Open the `Extensions` tab of VS Code. 7 | 3. Search for `@recommended`. 8 | 4. Make sure to install the `TypeScript + Webpack Problem Matchers` extension. 9 | 5. Run `ni` (or `pnpm i`) in terminal. 10 | 6. Press F5 to start debuging. 11 | 12 | ## Troubleshooting 13 | 14 | ### Task Error 15 | 16 | **Error messages:** 17 | 18 | - In `OUTPUT` panel of VS Code 👇. 19 | 20 | ``` 21 | Error: the description can't be converted into a problem matcher: 22 | { 23 | "base": "$ts-webpack-watch", 24 | "background": { 25 | "activeOnStart": true, 26 | "beginsPattern": "Build start", 27 | "endsPattern": "Build success" 28 | } 29 | } 30 | ``` 31 | 32 | - A popup saying like this 👇. 33 | 34 | ``` 35 | The task 'npm: dev' cannot be tracked. Make sure to have a problem matcher defined. 36 | ``` 37 | 38 | **Solution:** 39 | 40 | Please make sure you follow the steps above and install the recommended extension. This extension provides the `$ts-webpack-watch` problem matcher required in `.vscode/tasks.json`. 41 | 42 | ### Terminal Starting Error 43 | 44 | After pressing F5, if there isn't a new terminal named `Task` autostart in the `TERMINAL` panel of VS Code, and recommended extension is installed correctly, it may because VS Code doesn't load your `.zshrc` or other bash profiles correctly. 45 | 46 | **Error message:** 47 | 48 | - An error tip in the bottom right corner saying like this 👇. 49 | 50 | ``` 51 | The terminal process "/bin/zsh '-c', 'pnpm run dev'" terminated with exit code: 127. 52 | ``` 53 | 54 | **Solutions:** 55 | 56 | - Run `pnpm run dev` (or `nr dev`) manually. 57 | - Quit the VS Code in use and restart it from your terminal app by running the `code` command. 58 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Anthony Fu 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 | 2 | 3 | 4 | 5 | Smart Clicks VS Code 6 | 7 | 8 | 9 | 10 | 11 | 12 | Smart selection with double clicks for VS Code. 13 | GIF Demo 14 | 15 | 16 | ## Usage 17 | 18 | Double clicks on the code. 19 | 20 | ## Rules 21 | 22 | 23 | 24 | 25 | #### [`bracket-pair`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/bracket-pair.ts) 26 | 27 | Pair to inner content of brackets. 28 | 29 | ```js 30 | ▽ 31 | (foo, bar) 32 | └──────┘ 33 | ``` 34 | 35 | #### [`dash`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/dash.ts) 36 | 37 | `-` to identifier. 38 | 39 | ```css 40 | ▽ 41 | foo-bar 42 | └─────┘ 43 | ``` 44 | 45 | #### [`html-attr`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/html-attr.ts) 46 | 47 | `=` to HTML attribute. 48 | 49 | ```html 50 | ▽ 51 | 52 | └─────────┘ 53 | ``` 54 | 55 | #### [`html-element`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/html-element.ts) 56 | 57 | `<` to the entire element. 58 | 59 | ```html 60 | ▽ 61 | 62 | └────────────────────┘ 63 | ``` 64 | 65 | #### [`html-tag-pair`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/html-tag-pair.ts) 66 | 67 | Open and close tags of a HTML element. 68 | 69 | ```html 70 | ▽ 71 | 72 | └─┘ └─┘ 73 | ``` 74 | 75 | #### [`js-arrow-fn`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/js-arrow-fn.ts) 76 | 77 | `=>` to arrow function. 78 | 79 | ```js 80 | ▽ 81 | (a, b) => a + b 82 | └─────────────┘ 83 | ``` 84 | 85 | #### [`js-assign`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/js-assign.ts) 86 | 87 | `=` to assignment. 88 | 89 | ```js 90 | ▽ 91 | const a = [] 92 | └──────────┘ 93 | 94 | class B { 95 | ▽ 96 | b = 1; 97 | └────┘ 98 | ▽ 99 | ba = () => {}; 100 | └────────────┘ 101 | } 102 | ``` 103 | 104 | #### [`js-block`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/js-block.ts) 105 | 106 | Blocks like `if`, `for`, `while`, etc. in JavaScript. 107 | 108 | ```js 109 | ▽ 110 | function () { } 111 | └─────────────────┘ 112 | ``` 113 | 114 | ```js 115 | ▽ 116 | import { ref } from 'vue' 117 | └───────────────────────┘ 118 | ``` 119 | 120 | This rule is _disabled_ by default. 121 | 122 | #### [`js-colon`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/js-colon.ts) 123 | 124 | `:` to the value. 125 | 126 | ```js 127 | ▽ 128 | { foo: { bar } } 129 | └─────┘ 130 | ``` 131 | 132 | #### [`jsx-tag-pair`](https://github.com/antfu/vscode-smart-clicks/blob/main/src/rules/jsx-tag-pair.ts) 133 | 134 | Matches JSX elements' start and end tags. 135 | 136 | ```jsx 137 | ▽ 138 | (Hi) 139 | └───────┘ └───────┘ 140 | ``` 141 | 142 | 143 | 144 | ## Configuration 145 | 146 | All the rules are enabled by default. To disable a specific rule, set the rule to `false` in `smartClicks.rules` of your VS Code settings: 147 | 148 | ```jsonc 149 | // settings.json 150 | { 151 | "smartClicks.rules": { 152 | "dash": false, 153 | "html-element": false, 154 | "js-block": true 155 | } 156 | } 157 | ``` 158 | 159 | ## Commands 160 | 161 | | ID | Description | 162 | | --------------------- | ------------------------------------------------------------------- | 163 | | `smartClicks.trigger` | Trigger Smart Clicks in current cursor position without mouse click | 164 | 165 | Usage examples: 166 | 167 | 1. Command palette 168 | 169 | Invoke the command palette by typing `Ctrl+Shift+P` and then typing `Smart Clicks: Trigger`. 170 | 171 | 2. Keyboard shortcuts 172 | 173 | ```jsonc 174 | // keybindings.json 175 | { 176 | "key": "ctrl+alt+c", 177 | "command": "smartClicks.trigger", 178 | "when": "editorTextFocus" 179 | } 180 | ``` 181 | 182 | 3. Vim keybindings ([VSCodeVim](https://marketplace.visualstudio.com/items?itemName=vscodevim.vim) is needed) 183 | 184 | ```jsonc 185 | // settings.json 186 | { 187 | "vim.normalModeKeyBindings": [ 188 | { 189 | "before": ["leader", "c"], 190 | "commands": ["smartClicks.trigger"], 191 | } 192 | ] 193 | } 194 | ``` 195 | 196 | ## Sponsors 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | ## Credits 205 | 206 | Inspired by [HBuilderX](https://www.dcloud.io/hbuilderx.html), initiated by [恐粉龙](https://space.bilibili.com/432190144). 207 | 208 | ## License 209 | 210 | [MIT](./LICENSE) License © 2022 [Anthony Fu](https://github.com/antfu) 211 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu({ 4 | ignores: ['**/playground/**'], 5 | markdown: false, 6 | formatters: true, 7 | }) 8 | .removeRules( 9 | 'node/prefer-global/process', 10 | ) 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "publisher": "antfu", 3 | "name": "smart-clicks", 4 | "displayName": "Smart Clicks", 5 | "version": "0.2.1", 6 | "packageManager": "pnpm@9.7.1", 7 | "description": "Smart selection with double clicks", 8 | "author": "Anthony Fu ", 9 | "license": "MIT", 10 | "funding": "https://github.com/sponsors/antfu", 11 | "homepage": "https://github.com/antfu/vscode-smart-clicks#readme", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/antfu/vscode-smart-clicks" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/antfu/vscode-smart-clicks/issues" 18 | }, 19 | "categories": [ 20 | "Other" 21 | ], 22 | "sideEffects": false, 23 | "main": "./dist/index.js", 24 | "icon": "res/icon.png", 25 | "files": [ 26 | "LICENSE.md", 27 | "dist/*", 28 | "res/*" 29 | ], 30 | "engines": { 31 | "vscode": "^1.92.0" 32 | }, 33 | "activationEvents": [ 34 | "onStartupFinished" 35 | ], 36 | "contributes": { 37 | "configuration": { 38 | "properties": { 39 | "smartClicks.clicksInterval": { 40 | "type": "number", 41 | "default": 600, 42 | "description": "The interval between clicks in milliseconds." 43 | }, 44 | "smartClicks.triggerDelay": { 45 | "type": "number", 46 | "default": 150, 47 | "description": "The delay after triggering the selection. To prevent conflicting with normal selection." 48 | }, 49 | "smartClicks.htmlLanguageIds": { 50 | "type": "array", 51 | "items": { 52 | "type": "string" 53 | }, 54 | "default": [ 55 | "html", 56 | "vue", 57 | "svelte" 58 | ], 59 | "description": "Array of language IDs to enable html smartClicks" 60 | }, 61 | "smartClicks.rules": { 62 | "type": "object", 63 | "properties": { 64 | "bracket-pair": { 65 | "type": "boolean", 66 | "default": true, 67 | "description": "Pair to inner content of brackets.\n\n```js\n▽\n(foo, bar)\n └──────┘\n```" 68 | }, 69 | "dash": { 70 | "type": "boolean", 71 | "default": true, 72 | "description": "`-` to identifier.\n\n```css\n ▽\nfoo-bar\n└─────┘\n```" 73 | }, 74 | "html-attr": { 75 | "type": "boolean", 76 | "default": true, 77 | "description": "`=` to HTML attribute.\n\n```html\n ▽\n\n └─────────┘\n```" 78 | }, 79 | "html-element": { 80 | "type": "boolean", 81 | "default": true, 82 | "description": "`<` to the entire element.\n\n```html\n▽\n\n└────────────────────┘\n```" 83 | }, 84 | "html-tag-pair": { 85 | "type": "boolean", 86 | "default": true, 87 | "description": "Open and close tags of a HTML element.\n\n```html\n ▽\n\n └─┘ └─┘\n```" 88 | }, 89 | "js-arrow-fn": { 90 | "type": "boolean", 91 | "default": true, 92 | "description": "`=>` to arrow function.\n\n```js\n ▽\n(a, b) => a + b\n└─────────────┘\n```" 93 | }, 94 | "js-assign": { 95 | "type": "boolean", 96 | "default": true, 97 | "description": "`=` to assignment.\n\n```js\n ▽\nconst a = []\n└──────────┘\n```" 98 | }, 99 | "js-block": { 100 | "type": "boolean", 101 | "default": false, 102 | "description": "Blocks like `if`, `for`, `while`, etc. in JavaScript.\n\n```js\n▽\nfunction () { }\n└─────────────────┘\n```\n\n```js\n▽\nimport { ref } from 'vue'\n└───────────────────────┘\n```" 103 | }, 104 | "js-colon": { 105 | "type": "boolean", 106 | "default": true, 107 | "description": "`:` to the value.\n\n```js\n ▽\n{ foo: { bar } }\n └─────┘\n```" 108 | }, 109 | "jsx-tag-pair": { 110 | "type": "boolean", 111 | "default": true, 112 | "description": "Matches JSX elements' start and end tags.\n\n```jsx\n ▽\n(Hi)\n └───────┘ └───────┘\n```" 113 | } 114 | }, 115 | "description": "Rule toggles" 116 | } 117 | } 118 | }, 119 | "commands": [ 120 | { 121 | "title": "Smart Clicks: Trigger", 122 | "command": "smartClicks.trigger" 123 | } 124 | ] 125 | }, 126 | "scripts": { 127 | "build": "NODE_ENV=production tsdown", 128 | "dev": "NODE_ENV=development tsdown --watch", 129 | "lint": "eslint .", 130 | "vscode:prepublish": "nr build", 131 | "publish": "vsce publish --no-dependencies", 132 | "pack": "vsce package --no-dependencies", 133 | "test": "vitest", 134 | "typecheck": "tsc --noEmit", 135 | "release": "bumpp && nr publish", 136 | "readme": "esno ./scripts/docs.ts" 137 | }, 138 | "devDependencies": { 139 | "@antfu/eslint-config": "^2.26.0", 140 | "@antfu/ni": "^0.22.4", 141 | "@antfu/utils": "^0.7.10", 142 | "@babel/core": "^7.25.2", 143 | "@babel/parser": "^7.25.3", 144 | "@babel/traverse": "^7.25.3", 145 | "@babel/types": "^7.25.2", 146 | "@types/babel__traverse": "^7.20.6", 147 | "@types/node": "^22.4.0", 148 | "@types/vscode": "^1.92.0", 149 | "@vscode/vsce": "^3.0.0", 150 | "bumpp": "^9.5.1", 151 | "eslint": "^9.9.0", 152 | "eslint-plugin-format": "^0.1.2", 153 | "esno": "^4.7.0", 154 | "fast-glob": "^3.3.2", 155 | "node-html-parser": "^6.1.13", 156 | "pnpm": "^9.7.1", 157 | "rimraf": "^6.0.1", 158 | "tsdown": "^0.11.5", 159 | "typescript": "^5.5.4", 160 | "vite": "^5.4.1", 161 | "vitest": "^2.0.5" 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /playground/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "smartClicks.rules": { 3 | "dash": true, 4 | "html-element": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /playground/index.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Vitesse Lite 28 | 29 | 30 | 31 | Opinionated Vite Starter Template 32 | 33 | 34 | 35 | 36 | 51 | 52 | 53 | 58 | Go 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /playground/main.ts: -------------------------------------------------------------------------------- 1 | import { extname, isAbsolute, resolve } from 'pathe' 2 | import { isNodeBuiltin } from 'mlly' 3 | import { normalizeModuleId, normalizeRequestId, slash, toFilePath } from './utils' 4 | import type { ModuleCache, ViteNodeRunnerOptions } from './types' 5 | 6 | export const DEFAULT_REQUEST_STUBS = { 7 | '/@vite/client': { 8 | injectQuery: (id: string) => id, 9 | createHotContext() { 10 | return { 11 | accept: () => {}, 12 | prune: () => {}, 13 | dispose: () => {}, 14 | decline: () => {}, 15 | invalidate: () => {}, 16 | on: () => {}, 17 | } 18 | }, 19 | updateStyle() {}, 20 | }, 21 | } 22 | 23 | export class ModuleCacheMap extends Map { 24 | normalizePath(fsPath: string) { 25 | return normalizeModuleId(fsPath) 26 | } 27 | 28 | set(fsPath: string, mod: Partial) { 29 | fsPath = this.normalizePath(fsPath) 30 | if (!super.has(fsPath)) 31 | super.set(fsPath, mod) 32 | else 33 | Object.assign(super.get(fsPath), mod) 34 | return this 35 | } 36 | } 37 | 38 | export class ViteNodeRunner { 39 | root: string 40 | 41 | /** 42 | * Holds the cache of modules 43 | * Keys of the map are filepaths, or plain package names 44 | */ 45 | moduleCache: ModuleCacheMap 46 | 47 | constructor(public options: ViteNodeRunnerOptions) { 48 | this.root = options.root || process.cwd() 49 | this.moduleCache = options.moduleCache || new ModuleCacheMap() 50 | } 51 | 52 | async executeFile(file: string) { 53 | return await this.cachedRequest(`/@fs/${slash(resolve(file))}`, []) 54 | } 55 | 56 | async executeId(id: string) { 57 | return await this.cachedRequest(id, []) 58 | } 59 | 60 | /** @internal */ 61 | async cachedRequest(rawId: string, callstack: string[]) { 62 | const id = normalizeRequestId(rawId, this.options.base) 63 | const fsPath = toFilePath(id, this.root) 64 | 65 | if (this.moduleCache.get(fsPath)?.promise) 66 | return this.moduleCache.get(fsPath)?.promise 67 | 68 | const promise = this.directRequest(id, fsPath, callstack) 69 | this.moduleCache.set(fsPath, { promise }) 70 | 71 | return await promise 72 | } 73 | 74 | shouldResolveId(dep: string) { 75 | if (isNodeBuiltin(dep) || dep in (this.options.requestStubs || DEFAULT_REQUEST_STUBS)) 76 | return false 77 | 78 | return !isAbsolute(dep) || !extname(dep) 79 | } 80 | 81 | /** 82 | * Define if a module should be interop-ed 83 | * This function mostly for the ability to override by subclass 84 | */ 85 | shouldInterop(path: string, mod: any) { 86 | if (this.options.interopDefault === false) 87 | return false 88 | // never interop ESM modules 89 | // TODO: should also skip for `.js` with `type="module"` 90 | return !path.endsWith('.mjs') && 'default' in mod 91 | } 92 | 93 | /** 94 | * Import a module and interop it 95 | */ 96 | async interopedImport(path: string) { 97 | const mod = await import(path) 98 | 99 | if (this.shouldInterop(path, mod)) { 100 | const tryDefault = this.hasNestedDefault(mod) 101 | return new Proxy(mod, { 102 | get: proxyMethod('get', tryDefault), 103 | set: proxyMethod('set', tryDefault), 104 | has: proxyMethod('has', tryDefault), 105 | deleteProperty: proxyMethod('deleteProperty', tryDefault), 106 | }) 107 | } 108 | 109 | return mod 110 | } 111 | 112 | hasNestedDefault(target: any) { 113 | return '__esModule' in target && target.__esModule && 'default' in target.default 114 | } 115 | } 116 | 117 | async function exportAll(exports: any, sourceModule: any) { 118 | for (const key of sourceModule) { 119 | if (key !== 'default') { 120 | try { 121 | Object.defineProperty(exports, key, { 122 | enumerable: true, 123 | configurable: true, 124 | get: () => { return sourceModule[key] }, 125 | }) 126 | } 127 | finally { 128 | console.log() 129 | } 130 | } 131 | else if (hi) { 132 | 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | - examples/* 4 | -------------------------------------------------------------------------------- /res/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/antfu/vscode-smart-clicks/1388b259cda55e4c3a2b9d9108ea1dfd5918b634/res/icon.png -------------------------------------------------------------------------------- /scripts/docs.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs' 2 | import fg from 'fast-glob' 3 | import pkg from '../package.json' 4 | 5 | interface Parsed { 6 | name: string 7 | category?: string 8 | content: string 9 | file: string 10 | } 11 | 12 | const GITHUB_URL = 'https://github.com/antfu/vscode-smart-clicks/blob/main/' 13 | 14 | async function run() { 15 | let readme = await fs.readFile('README.md', 'utf-8') 16 | const files = await fg('src/rules/*.ts', { 17 | ignore: ['index.ts'], 18 | }) 19 | 20 | files.sort() 21 | 22 | const parsed: Parsed[] = [] 23 | 24 | for (const file of files) { 25 | const content = await fs.readFile(file, 'utf-8') 26 | const match = content.match(/\/\*[\s\S]+?\*\//)?.[0] 27 | if (!match) 28 | continue 29 | const info = { 30 | file, 31 | } as Parsed 32 | const lines = match.split('\n') 33 | .map(i => i.replace(/^\s*[/*]+\s?/, '').trimEnd()) 34 | .filter((i) => { 35 | if (i.startsWith('@name ')) { 36 | info.name = i.slice(6) 37 | return false 38 | } 39 | if (i.startsWith('@category ')) { 40 | info.category = i.slice('@category '.length) 41 | return false 42 | } 43 | return true 44 | }) 45 | info.content = lines.join('\n').trim() 46 | parsed.push(info) 47 | } 48 | 49 | const content = parsed.map((i) => { 50 | return `#### [\`${i.name}\`](${GITHUB_URL + i.file})\n\n${i.content}` 51 | }).join('\n\n') 52 | 53 | readme = readme.replace(/[\s\S]*/, `\n${content}\n`) 54 | await fs.writeFile('README.md', readme, 'utf-8') 55 | 56 | const props: any = {} 57 | parsed.forEach((i) => { 58 | props[i.name] = { 59 | type: 'boolean', 60 | default: true, 61 | description: i.content, 62 | } 63 | }) 64 | 65 | pkg.contributes.configuration.properties['smartClicks.rules'].properties = props as any 66 | await fs.writeFile('package.json', JSON.stringify(pkg, null, 2), 'utf-8') 67 | } 68 | 69 | run() 70 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import { Range } from 'vscode' 2 | import type { Position, Selection, TextDocument } from 'vscode' 3 | import type { AstIdMap, AstLang, HandlerContext } from './types' 4 | import { astCache } from './index' 5 | 6 | export function createContext( 7 | doc: TextDocument, 8 | prevSelection: Selection, 9 | selection: Selection, 10 | ) { 11 | const anchor = prevSelection.start 12 | const anchorIndex = doc.offsetAt(anchor) 13 | 14 | const charLeft = doc.getText(new Range(anchor, withOffset(anchor, -1))) 15 | const charRight = doc.getText(new Range(anchor, withOffset(anchor, 1))) 16 | const char = doc.offsetAt(selection.end) >= doc.offsetAt(anchor) 17 | ? charRight 18 | : charLeft 19 | 20 | if (!astCache.has(doc.uri.fsPath)) 21 | astCache.set(doc.uri.fsPath, []) 22 | const ast = astCache.get(doc.uri.fsPath)! 23 | 24 | function withOffset(p: Position, offset: number) { 25 | if (offset === 0) 26 | return p 27 | return doc.positionAt(doc.offsetAt(p) + offset) 28 | } 29 | 30 | function getAst(lang: T): AstIdMap[T][] { 31 | return ast.filter(i => i.type === lang && i.start <= anchorIndex && i.end >= anchorIndex && i.root) as AstIdMap[T][] 32 | } 33 | 34 | const context: HandlerContext = { 35 | doc, 36 | langId: doc.languageId, 37 | anchor, 38 | anchorIndex, 39 | selection, 40 | withOffset, 41 | char, 42 | charLeft, 43 | charRight, 44 | chars: [charLeft, charRight], 45 | ast, 46 | getAst, 47 | } 48 | 49 | return context 50 | } 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { ExtensionContext, Selection, TextEditor } from 'vscode' 2 | import { TextEditorSelectionChangeKind, commands, window, workspace } from 'vscode' 3 | import type { AstRoot } from './types' 4 | import { trigger } from './trigger' 5 | 6 | export const astCache = new Map() 7 | 8 | export function activate(ext: ExtensionContext) { 9 | let last = 0 10 | let prevEditor: TextEditor | undefined 11 | let prevSelection: Selection | undefined 12 | let timer: any 13 | 14 | const config = workspace.getConfiguration('smartClicks') 15 | 16 | ext.subscriptions.push( 17 | workspace.onDidChangeTextDocument((e) => { 18 | astCache.delete(e.document.uri.fsPath) 19 | }), 20 | 21 | window.onDidChangeTextEditorSelection(async (e) => { 22 | clearTimeout(timer) 23 | if (e.kind !== TextEditorSelectionChangeKind.Mouse) { 24 | last = 0 25 | return 26 | } 27 | 28 | const selection = e.selections[0] 29 | const prev = prevSelection 30 | 31 | try { 32 | if ( 33 | prevEditor !== e.textEditor 34 | || !prevSelection 35 | || !prevSelection.isEmpty 36 | || e.selections.length !== 1 37 | || selection.start.line !== prevSelection.start.line 38 | || Date.now() - last > config.get('clicksInterval', 600) 39 | ) { 40 | return 41 | } 42 | } 43 | finally { 44 | prevEditor = e.textEditor 45 | prevSelection = selection 46 | last = Date.now() 47 | } 48 | 49 | timer = setTimeout(async () => { 50 | const line = Math.max(0, e.textEditor.selection.active.line - 1) 51 | const { rangeIncludingLineBreak } = e.textEditor.document.lineAt(line) 52 | 53 | if (rangeIncludingLineBreak.isEqual(selection)) 54 | return 55 | const newSelection = await trigger(e.textEditor.document, prev!, selection) 56 | const newSelectionText = e.textEditor.document.getText(newSelection?.[0]) 57 | // Skip empty results when selecting text like "/>", "{}", "()" 58 | if (newSelection && newSelectionText) { 59 | last = 0 60 | e.textEditor.selections = newSelection 61 | } 62 | }, config.get('triggerDelay', 150)) 63 | }), 64 | 65 | commands.registerCommand( 66 | 'smartClicks.trigger', 67 | async () => { 68 | const editor = window.activeTextEditor 69 | if (!editor) 70 | return 71 | 72 | const prev = editor.selections[0] 73 | await commands.executeCommand('editor.action.smartSelect.expand') 74 | const selection = editor.selections[0] 75 | 76 | if (editor.selections.length !== 1) 77 | return 78 | 79 | const newSelection = await trigger(editor.document, prev, selection) 80 | const newSelectionText = editor.document.getText(newSelection?.[0]) 81 | 82 | if (newSelection && newSelectionText) 83 | editor.selections = newSelection 84 | }, 85 | ), 86 | ) 87 | } 88 | 89 | export function deactivate() { 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | import { window } from 'vscode' 2 | 3 | export const isDebug = process.env.NODE_ENV === 'development' 4 | 5 | export const channel = window.createOutputChannel('Smart Click') 6 | 7 | export const log = { 8 | debug(...args: any[]) { 9 | if (!isDebug) 10 | return 11 | // eslint-disable-next-line no-console 12 | console.log(...args) 13 | this.log(...args) 14 | }, 15 | 16 | log(...args: any[]) { 17 | channel.appendLine(args.map(i => String(i)).join(' ')) 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /src/parsers/html.ts: -------------------------------------------------------------------------------- 1 | import type { HTMLElement } from 'node-html-parser' 2 | import { parse } from 'node-html-parser' 3 | import { workspace } from 'vscode' 4 | import type { Parser } from '../types' 5 | import { parseJS } from './javascript' 6 | 7 | export const htmlParser: Parser = { 8 | name: 'html', 9 | handle: async ({ ast, langId, doc }) => { 10 | const id = 'html-root' 11 | if (ast.find(i => i.id === id)) 12 | return 13 | 14 | const config = workspace.getConfiguration('smartClicks') 15 | if (!config.get('htmlLanguageIds', []).includes(langId)) 16 | return 17 | 18 | const code = doc.getText() 19 | const root = parse(code, { 20 | comment: true, 21 | }) 22 | 23 | ast.push({ 24 | type: 'html', 25 | id, 26 | start: 0, 27 | end: code.length, 28 | root, 29 | raw: code, 30 | }) 31 | 32 | let htmlScriptCount = 0 33 | for (const node of traverseHTML(root)) { 34 | if (node.rawTagName === 'script') { 35 | const script = node.childNodes[0] 36 | const raw = node.innerHTML 37 | const start = script.range[0] 38 | const id = `html-script-${htmlScriptCount++}` 39 | ast.push(parseJS(raw, id, start)) 40 | } 41 | } 42 | }, 43 | } 44 | 45 | export function *traverseHTML(node: HTMLElement): Generator { 46 | yield node 47 | for (const child of node.childNodes) 48 | yield * traverseHTML(child as HTMLElement) 49 | } 50 | -------------------------------------------------------------------------------- /src/parsers/index.ts: -------------------------------------------------------------------------------- 1 | import type { HandlerContext, Parser } from '../types' 2 | import { htmlParser } from './html' 3 | import { jsParser } from './javascript' 4 | 5 | export const parsers: Parser[] = [ 6 | jsParser, 7 | htmlParser, 8 | ] 9 | 10 | export async function applyParser(context: HandlerContext) { 11 | for (const parser of parsers) 12 | await parser.handle(context) 13 | } 14 | -------------------------------------------------------------------------------- /src/parsers/javascript.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '@babel/parser' 2 | import { channel } from '../log' 3 | import type { AstRoot, Parser } from '../types' 4 | 5 | export const jsParser: Parser = { 6 | name: 'js', 7 | handle: async ({ ast, doc, langId }) => { 8 | const id = 'js-root' 9 | if (ast.find(i => i.id === id)) 10 | return 11 | 12 | if (!['javascript', 'typescript', 'javascriptreact', 'typescriptreact'].includes(langId)) 13 | return 14 | 15 | try { 16 | ast.push(parseJS(doc.getText(), id, 0)) 17 | } 18 | catch (e) { 19 | channel.appendLine(`Failed to parse ${doc.uri.fsPath}`) 20 | channel.appendLine(String(e)) 21 | } 22 | }, 23 | } 24 | 25 | export function parseJS(code: string, id: string, start = 0): AstRoot { 26 | const root = parse( 27 | code, 28 | { 29 | sourceType: 'unambiguous', 30 | plugins: [ 31 | 'jsx', 32 | 'typescript', 33 | ], 34 | }, 35 | ) 36 | return { 37 | type: 'js', 38 | id, 39 | start, 40 | end: start + code.length, 41 | root, 42 | raw: code, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/rules/bracket-pair.ts: -------------------------------------------------------------------------------- 1 | import { Position, Range, Selection } from 'vscode' 2 | import type { Handler } from '../types' 3 | 4 | const bracketPairs: [left: string, right: string][] = [ 5 | ['(', ')'], 6 | ['[', ']'], 7 | ['{', '}'], 8 | ['<', '>'], 9 | ['"', '"'], 10 | ['`', '`'], 11 | ['\'', '\''], 12 | ] 13 | 14 | /** 15 | * Pair to inner content of brackets. 16 | * 17 | * ```js 18 | * ▽ 19 | * (foo, bar) 20 | * └──────┘ 21 | * ``` 22 | * 23 | * @name bracket-pair 24 | */ 25 | export const bracketPairHandler: Handler = { 26 | name: 'bracket-pair', 27 | handle({ charLeft, charRight, doc, anchor: _anchor, withOffset }) { 28 | for (const DIR of [1, -1]) { 29 | const OPEN = DIR === 1 ? 0 : 1 30 | const CLOSE = DIR === 1 ? 1 : 0 31 | 32 | const bracketLeft = bracketPairs.find(i => i[OPEN] === charLeft) 33 | const bracketRight = bracketPairs.find(i => i[OPEN] === charRight) 34 | const bracket = bracketLeft || bracketRight 35 | const anchor = bracketLeft ? withOffset(_anchor, -1) : _anchor 36 | 37 | if (!bracket) 38 | continue 39 | 40 | const start = withOffset(anchor, DIR) 41 | const rest = doc.getText( 42 | DIR === 1 43 | ? new Range(start, new Position(Infinity, Infinity)) 44 | : new Range(new Position(0, 0), start), 45 | ) 46 | 47 | // search for the right bracket 48 | let index = -1 49 | let curly = 0 50 | for (let i = 0; i < rest.length; i += 1) { 51 | const idx = (rest.length + i * DIR) % rest.length 52 | const c = rest[idx] 53 | if (rest[idx - 1] === '\\') 54 | continue 55 | if (c === bracket[OPEN]) { 56 | curly++ 57 | } 58 | else if (c === bracket[CLOSE]) { 59 | curly-- 60 | if (curly < 0) { 61 | index = i 62 | break 63 | } 64 | } 65 | } 66 | 67 | if (index < 0) 68 | continue 69 | 70 | if (DIR === 1) { 71 | return new Selection( 72 | start, 73 | withOffset(start, index), 74 | ) 75 | } 76 | else { 77 | return new Selection( 78 | withOffset(start, index * DIR + 1), 79 | withOffset(start, 1), 80 | ) 81 | } 82 | } 83 | }, 84 | } 85 | -------------------------------------------------------------------------------- /src/rules/dash.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from '../types' 2 | 3 | /** 4 | * `-` to identifier. 5 | * 6 | * ```css 7 | * ▽ 8 | * foo-bar 9 | * └─────┘ 10 | * ``` 11 | * 12 | * @name dash 13 | */ 14 | export const dashHandler: Handler = { 15 | name: 'dash', 16 | handle({ charLeft, charRight, doc, anchor }) { 17 | if (charLeft === '-' || charRight === '-') 18 | return doc.getWordRangeAtPosition(anchor, /[\w-]+/) 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/rules/html-attr.ts: -------------------------------------------------------------------------------- 1 | import type { Handler } from '../types' 2 | 3 | /** 4 | * `=` to HTML attribute. 5 | * 6 | * ```html 7 | * ▽ 8 | * 9 | * └─────────┘ 10 | * ``` 11 | * 12 | * @name html-attr 13 | * @category html 14 | */ 15 | export const htmlAttrHandler: Handler = { 16 | name: 'html-attr', 17 | handle({ getAst, doc, anchor }) { 18 | const asts = getAst('html') 19 | if (!asts.length) 20 | return 21 | 22 | const range = doc.getWordRangeAtPosition(anchor, /=/) 23 | if (!range) 24 | return 25 | 26 | return doc.getWordRangeAtPosition(anchor, /[\w.:@-]+=(["']).*?\1|[\w.:@-]+=\{.*?\}/) 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/rules/html-element.ts: -------------------------------------------------------------------------------- 1 | import { Selection } from 'vscode' 2 | import { traverseHTML } from '../parsers/html' 3 | import type { Handler } from '../types' 4 | 5 | /** 6 | * `<` to the entire element. 7 | * 8 | * ```html 9 | * ▽ 10 | * 11 | * └────────────────────┘ 12 | * ``` 13 | * 14 | * @name html-element 15 | * @category html 16 | */ 17 | export const htmlElementHandler: Handler = { 18 | name: 'html-element', 19 | handle({ getAst, doc, anchor }) { 20 | const asts = getAst('html') 21 | if (!asts.length) 22 | return 23 | 24 | const range = doc.getWordRangeAtPosition(anchor, /\s{0,2}) 25 | if (!range) 26 | return 27 | const targetIndex = doc.offsetAt(range.end) - 1 28 | 29 | for (const ast of asts) { 30 | for (const node of traverseHTML(ast.root)) { 31 | if (node.range[0] + ast.start === targetIndex) { 32 | return new Selection( 33 | doc.positionAt(node.range[0] + ast.start), 34 | doc.positionAt(node.range[1] + ast.start), 35 | ) 36 | } 37 | } 38 | } 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /src/rules/html-tag-pair.ts: -------------------------------------------------------------------------------- 1 | import { Range, Selection } from 'vscode' 2 | import { traverseHTML } from '../parsers/html' 3 | import type { Handler } from '../types' 4 | 5 | /** 6 | * Open and close tags of a HTML element. 7 | * 8 | * ```html 9 | * ▽ 10 | * 11 | * └─┘ └─┘ 12 | * ``` 13 | * 14 | * @name html-tag-pair 15 | * @category html 16 | */ 17 | export const htmlTagPairHandler: Handler = { 18 | name: 'html-tag-pair', 19 | handle({ getAst, selection, doc, withOffset }) { 20 | const asts = getAst('html') 21 | if (!asts.length) 22 | return 23 | 24 | const range = doc.getWordRangeAtPosition(selection.start, /[\w.\-]+/) || selection 25 | const rangeText = doc.getText(range) 26 | const preCharPos = withOffset(range.start, -1) 27 | const preChar = doc.getText(new Range(preCharPos, range.start)) 28 | const postCharPos = withOffset(range.end, 1) 29 | const postChar = doc.getText(new Range(range.end, postCharPos)) 30 | 31 | const preIndex = preChar === '<' ? doc.offsetAt(preCharPos) : -1 32 | const postIndex = postChar === '>' ? doc.offsetAt(postCharPos) : -1 33 | 34 | if (postIndex < 0 && preIndex < 0) 35 | return 36 | 37 | for (const ast of asts) { 38 | for (const node of traverseHTML(ast.root)) { 39 | if (node.rawTagName !== rangeText || !('isVoidElement' in node) || node.isVoidElement) 40 | continue 41 | 42 | // from start tag to end tag 43 | if (node.range[0] + ast.start === preIndex) { 44 | const body = doc.getText(new Range( 45 | preCharPos, 46 | doc.positionAt(node.range[1] + ast.start), 47 | )) 48 | 49 | if (body.trimEnd().endsWith('/>')) 50 | return range 51 | 52 | const endIndex = body.lastIndexOf(`${node.rawTagName}>`) 53 | if (endIndex) { 54 | return [ 55 | range, 56 | new Selection( 57 | doc.positionAt(preIndex + endIndex + 2), 58 | doc.positionAt(preIndex + endIndex + 2 + node.rawTagName.length), 59 | ), 60 | ] 61 | } 62 | } 63 | 64 | // from end tag to start tag 65 | if (node.range[1] === postIndex) { 66 | return [ 67 | new Selection( 68 | doc.positionAt(node.range[0] + 1), 69 | doc.positionAt(node.range[0] + 1 + node.rawTagName.length), 70 | ), 71 | range, 72 | ] 73 | } 74 | } 75 | } 76 | }, 77 | } 78 | -------------------------------------------------------------------------------- /src/rules/index.ts: -------------------------------------------------------------------------------- 1 | import { toArray } from '@antfu/utils' 2 | import type { Range, Selection } from 'vscode' 3 | import { workspace } from 'vscode' 4 | import { log } from '../log' 5 | import type { Handler, HandlerContext } from '../types' 6 | import { bracketPairHandler } from './bracket-pair' 7 | import { dashHandler } from './dash' 8 | import { htmlAttrHandler } from './html-attr' 9 | import { htmlElementHandler } from './html-element' 10 | import { htmlTagPairHandler } from './html-tag-pair' 11 | import { jsArrowFnHandler } from './js-arrow-fn' 12 | import { jsAssignHandler } from './js-assign' 13 | import { jsBlockHandler } from './js-block' 14 | import { jsColonHandler } from './js-colon' 15 | import { jsxTagPairHandler } from './jsx-tag-pair' 16 | 17 | export const handlers: Handler[] = [ 18 | // html 19 | htmlTagPairHandler, 20 | htmlElementHandler, 21 | htmlAttrHandler, 22 | 23 | // js 24 | jsxTagPairHandler, 25 | jsArrowFnHandler, 26 | jsBlockHandler, 27 | jsAssignHandler, 28 | jsColonHandler, 29 | 30 | // general 31 | dashHandler, 32 | bracketPairHandler, 33 | ] 34 | 35 | function stringify(range: Range | Selection) { 36 | return `${range.start.line}:${range.start.character}->${range.end.line}:${range.end.character}` 37 | } 38 | 39 | export function applyHandlers(context: HandlerContext) { 40 | const config = workspace.getConfiguration('smartClicks') 41 | const rulesOptions = config.get('rules', {}) as any 42 | 43 | for (const handler of handlers) { 44 | if (rulesOptions[handler.name] === false) 45 | continue 46 | let selection = handler.handle(context) 47 | if (selection) { 48 | selection = toArray(selection) 49 | log.log(`[${handler.name}] ${selection.map(stringify).join(', ')}`) 50 | return selection 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/rules/js-arrow-fn.ts: -------------------------------------------------------------------------------- 1 | import traverse from '@babel/traverse' 2 | import { Selection } from 'vscode' 3 | import type { Handler } from '../types' 4 | 5 | const supportedNodeType = [ 6 | 'ArrowFunctionExpression', 7 | ] 8 | 9 | /** 10 | * `=>` to arrow function. 11 | * 12 | * ```js 13 | * ▽ 14 | * (a, b) => a + b 15 | * └─────────────┘ 16 | * ``` 17 | * 18 | * @name js-arrow-fn 19 | * @category js 20 | */ 21 | export const jsArrowFnHandler: Handler = { 22 | name: 'js-arrow-fn', 23 | handle({ doc, getAst, anchorIndex, anchor, chars }) { 24 | if (!chars.includes('=')) 25 | return 26 | 27 | const asts = getAst('js') 28 | if (!asts.length) 29 | return 30 | 31 | const range = doc.getWordRangeAtPosition(anchor, /=>/) 32 | if (!range || range.isEmpty) 33 | return 34 | 35 | for (const ast of getAst('js')) { 36 | const relativeIndex = anchorIndex - ast.start 37 | 38 | let result: Selection | undefined 39 | traverse(ast.root, { 40 | enter(path) { 41 | if (path.node.start == null || path.node.end == null) 42 | return 43 | if (relativeIndex > path.node.end || path.node.start > relativeIndex) 44 | return path.skip() 45 | if (!supportedNodeType.includes(path.node.type)) { 46 | // log.debug(`[js-arrow-fn] Unknown ${path.node.type}`) 47 | return 48 | } 49 | result = new Selection( 50 | doc.positionAt(ast.start + path.node.start), 51 | doc.positionAt(ast.start + path.node.end), 52 | ) 53 | }, 54 | }) 55 | 56 | if (result) 57 | return result 58 | } 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /src/rules/js-assign.ts: -------------------------------------------------------------------------------- 1 | import traverse from '@babel/traverse' 2 | import { Range, Selection } from 'vscode' 3 | 4 | // import { log } from '../log' 5 | import type { Handler } from '../types' 6 | 7 | const supportedNodeType = [ 8 | 'TSTypeAliasDeclaration', 9 | 'VariableDeclaration', 10 | 'AssignmentExpression', 11 | 'ClassProperty', 12 | ] 13 | 14 | /** 15 | * `=` to assignment. 16 | * 17 | * ```js 18 | * ▽ 19 | * const a = [] 20 | * └──────────┘ 21 | * 22 | * class B { 23 | * ▽ 24 | * b = 1; 25 | * └────┘ 26 | * ▽ 27 | * ba = () => {}; 28 | * └────────────┘ 29 | * } 30 | * ``` 31 | * 32 | * @name js-assign 33 | * @category js 34 | */ 35 | export const jsAssignHandler: Handler = { 36 | name: 'js-assign', 37 | handle({ doc, getAst, chars, anchorIndex, withOffset, anchor }) { 38 | if (!chars.includes('=')) 39 | return 40 | 41 | const asts = getAst('js') 42 | if (!asts.length) 43 | return 44 | 45 | if (doc.getText(new Range(anchor, withOffset(anchor, 2))).includes('=>')) 46 | return 47 | 48 | for (const ast of getAst('js')) { 49 | const relativeIndex = anchorIndex - ast.start 50 | 51 | let result: Selection | undefined 52 | traverse(ast.root, { 53 | enter(path) { 54 | if (path.node.start == null || path.node.end == null) 55 | return 56 | if (relativeIndex > path.node.end || path.node.start > relativeIndex) 57 | return path.skip() 58 | if (!supportedNodeType.includes(path.node.type)) { 59 | // log.debug('[js-assign] Unknown type:', path.node.type) 60 | return 61 | } 62 | result = new Selection( 63 | doc.positionAt(ast.start + path.node.start), 64 | doc.positionAt(ast.start + path.node.end), 65 | ) 66 | }, 67 | }) 68 | 69 | if (result) 70 | return result 71 | } 72 | }, 73 | } 74 | -------------------------------------------------------------------------------- /src/rules/js-block.ts: -------------------------------------------------------------------------------- 1 | import traverse from '@babel/traverse' 2 | import { Selection } from 'vscode' 3 | import type { Node } from '@babel/types' 4 | 5 | // import { log } from '../log' 6 | import type { Handler } from '../types' 7 | 8 | const supportedNodeType = [ 9 | 'BlockStatement', 10 | 'CatchClause', 11 | 'ClassDeclaration', 12 | 'DoWhileStatement', 13 | 'ExportAllDeclaration', 14 | 'ExportDefaultDeclaration', 15 | 'ExportNamedDeclaration', 16 | 'ForStatement', 17 | 'ForInStatement', 18 | 'ForOfStatement', 19 | 'FunctionDeclaration', 20 | 'IfStatement', 21 | 'ImportDeclaration', 22 | 'SwitchStatement', 23 | 'TryStatement', 24 | 'TSInterfaceDeclaration', 25 | 'WhileStatement', 26 | ] 27 | 28 | /** 29 | * Blocks like `if`, `for`, `while`, etc. in JavaScript. 30 | * 31 | * ```js 32 | * ▽ 33 | * function () { } 34 | * └─────────────────┘ 35 | * ``` 36 | * 37 | * ```js 38 | * ▽ 39 | * import { ref } from 'vue' 40 | * └───────────────────────┘ 41 | * ``` 42 | * 43 | * @name js-block 44 | * @category js 45 | */ 46 | export const jsBlockHandler: Handler = { 47 | name: 'js-block', 48 | handle({ selection, doc, getAst }) { 49 | const selectionText = doc.getText(selection) 50 | if (selectionText === 'async') 51 | return 52 | 53 | for (const ast of getAst('js')) { 54 | const index = doc.offsetAt(selection.start) 55 | const relativeIndex = index - ast.start 56 | 57 | let node: Node | undefined 58 | traverse(ast.root, { 59 | enter(path) { 60 | if (path.node.start == null || path.node.end == null) 61 | return 62 | if (relativeIndex > path.node.end || path.node.start > relativeIndex) 63 | return path.skip() 64 | 65 | if (!supportedNodeType.includes(path.node.type)) { 66 | // log.debug('[js-block] Unknown type:', path.node.type) 67 | return 68 | } 69 | node = path.node 70 | }, 71 | }) 72 | 73 | if (!node) 74 | continue 75 | 76 | let start = node.start 77 | let end = node.end 78 | 79 | // if ... else 80 | if ( 81 | node.type === 'IfStatement' 82 | && node.alternate 83 | && node.consequent.end! <= relativeIndex 84 | && node.alternate.start! > relativeIndex 85 | ) { 86 | start = node.consequent.end 87 | end = node.alternate.end 88 | } 89 | // try ... finally 90 | else if ( 91 | node.type === 'TryStatement' 92 | && node.finalizer 93 | && (node.handler?.end ?? node.block.end!) <= relativeIndex 94 | && node.finalizer.start! - relativeIndex > 4 95 | ) { 96 | start = (node.handler?.end || node.block.end!) 97 | end = node.finalizer.end 98 | } 99 | else if (node.start !== relativeIndex) { 100 | continue 101 | } 102 | 103 | return new Selection( 104 | doc.positionAt(ast.start + start!), 105 | doc.positionAt(ast.start + end!), 106 | ) 107 | } 108 | }, 109 | } 110 | -------------------------------------------------------------------------------- /src/rules/js-colon.ts: -------------------------------------------------------------------------------- 1 | import traverse from '@babel/traverse' 2 | import { Selection } from 'vscode' 3 | import type { Handler } from '../types' 4 | 5 | /** 6 | * `:` to the value. 7 | * 8 | * ```js 9 | * ▽ 10 | * { foo: { bar } } 11 | * └─────┘ 12 | * ``` 13 | * 14 | * @name js-colon 15 | * @category js 16 | */ 17 | export const jsColonHandler: Handler = { 18 | name: 'js-colon', 19 | handle({ doc, getAst, anchor }) { 20 | const asts = getAst('js') 21 | if (!asts.length) 22 | return 23 | 24 | const range = doc.getWordRangeAtPosition(anchor, /\s*:\s*/) 25 | if (!range) 26 | return 27 | 28 | for (const ast of getAst('js')) { 29 | const relativeIndex = doc.offsetAt(range.end) - ast.start 30 | 31 | let result: Selection | undefined 32 | traverse(ast.root, { 33 | enter(path) { 34 | if (result) 35 | return path.skip() 36 | if (path.node.start == null || path.node.end == null) 37 | return 38 | if (relativeIndex > path.node.end || path.node.start > relativeIndex) 39 | return path.skip() 40 | if (path.node.start !== relativeIndex) 41 | return 42 | result = new Selection( 43 | doc.positionAt(ast.start + path.node.start), 44 | doc.positionAt(ast.start + path.node.end), 45 | ) 46 | }, 47 | }) 48 | 49 | if (result) 50 | return result 51 | } 52 | }, 53 | } 54 | -------------------------------------------------------------------------------- /src/rules/jsx-tag-pair.ts: -------------------------------------------------------------------------------- 1 | import traverse from '@babel/traverse' 2 | import { Selection } from 'vscode' 3 | import type { Handler } from '../types' 4 | 5 | /** 6 | * Matches JSX elements' start and end tags. 7 | * 8 | * ```jsx 9 | * ▽ 10 | * (Hi) 11 | * └───────┘ └───────┘ 12 | * ``` 13 | * 14 | * @name jsx-tag-pair 15 | * @category js 16 | */ 17 | export const jsxTagPairHandler: Handler = { 18 | name: 'jsx-tag-pair', 19 | handle({ selection, doc, getAst }) { 20 | for (const ast of getAst('js')) { 21 | const index = doc.offsetAt(selection.start) 22 | 23 | let result: Selection[] | undefined 24 | traverse(ast.root, { 25 | JSXElement(path) { 26 | if (result) 27 | return path.skip() 28 | if (path.node.start == null || path.node.end == null) 29 | return 30 | 31 | const elements = [ 32 | path.node.openingElement!, 33 | path.node.closingElement!, 34 | ].filter(Boolean) 35 | 36 | if (!elements.length) 37 | return 38 | 39 | if (elements.some(e => e.name.start != null && e.name.start + ast.start === index)) { 40 | result = elements.map(e => new Selection( 41 | doc.positionAt(ast.start + e.name.start!), 42 | doc.positionAt(ast.start + e.name.end!), 43 | )) 44 | } 45 | }, 46 | }) 47 | 48 | if (result) 49 | return result 50 | } 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /src/trigger.ts: -------------------------------------------------------------------------------- 1 | import type { Selection, TextDocument } from 'vscode' 2 | import { toArray } from '@antfu/utils' 3 | import { applyHandlers } from './rules' 4 | import { applyParser } from './parsers' 5 | import { toSelection } from './utils' 6 | import { log } from './log' 7 | import { createContext } from './context' 8 | 9 | export async function trigger( 10 | doc: TextDocument, 11 | prevSelection: Selection, 12 | selection: Selection, 13 | ) { 14 | const context = createContext(doc, prevSelection, selection) 15 | 16 | log.debug(context) 17 | 18 | await applyParser(context) 19 | 20 | const newSelection = applyHandlers(context) 21 | if (newSelection) 22 | return toArray(newSelection).map(toSelection) 23 | return undefined 24 | } 25 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ParseResult as AstRootJS } from '@babel/parser' 2 | import type { Position, Range, Selection, TextDocument } from 'vscode' 3 | import type { HTMLElement as AstRootHTML } from 'node-html-parser' 4 | 5 | export interface HandlerContext { 6 | doc: TextDocument 7 | langId: string 8 | anchor: Position 9 | anchorIndex: number 10 | selection: Selection 11 | char: string 12 | charLeft: string 13 | charRight: string 14 | chars: string[] 15 | withOffset: (p: Position, offset: number) => Position 16 | ast: AstRoot[] 17 | getAst: (lang: T) => AstIdMap[T][] 18 | } 19 | 20 | export interface Handler { 21 | name: string 22 | handle: (context: HandlerContext) => Selection | Range | Selection[] | Range [] | void 23 | } 24 | 25 | export interface Parser { 26 | name: string 27 | handle: (context: HandlerContext) => Promise | void 28 | } 29 | 30 | export interface AstBase { 31 | start: number 32 | end: number 33 | raw: string 34 | id: string 35 | } 36 | 37 | export interface AstJS extends AstBase { 38 | type: 'js' 39 | root: AstRootJS 40 | } 41 | 42 | export interface AstHTML extends AstBase { 43 | type: 'html' 44 | root: AstRootHTML 45 | } 46 | 47 | export interface AstIdMap { 48 | js: AstJS 49 | html: AstHTML 50 | } 51 | 52 | export type AstLang = keyof AstIdMap 53 | 54 | export type AstRoot = AstHTML | AstJS 55 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Range, Selection } from 'vscode' 2 | 3 | export function escapeRegExp(str: string) { 4 | return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 5 | } 6 | 7 | export function toSelection(range: Range | Selection) { 8 | if (range instanceof Range) 9 | return new Selection(range.start, range.end) 10 | return range 11 | } 12 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | describe('should', () => { 4 | it('exported', () => { 5 | expect(1).toEqual(1) 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["esnext"], 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "types": [ 9 | "@babel/types" 10 | ], 11 | "strict": true, 12 | "strictNullChecks": true, 13 | "esModuleInterop": true, 14 | "skipDefaultLibCheck": true, 15 | "skipLibCheck": true 16 | }, 17 | "exclude": [ 18 | "playground/**" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | 3 | export default defineConfig({ 4 | entry: [ 5 | 'src/index.ts', 6 | ], 7 | format: ['cjs'], 8 | env: { 9 | NODE_ENV: process.env.NODE_ENV || 'production', 10 | }, 11 | external: [ 12 | 'vscode', 13 | ], 14 | }) 15 | --------------------------------------------------------------------------------
2 | 3 |
8 | 9 |
12 | Smart selection with double clicks for VS Code. 13 | GIF Demo 14 |
199 | 200 | 201 | 202 |
26 | 27 | Vitesse Lite 28 | 29 |
31 | Opinionated Vite Starter Template 32 |