mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 22:10:15 +03:00
Fitering entries in Vue IDE + suggestion database mock (#7804)
Fixes #7737 Added structures representing Suggestion Database entries. Currently, the db is loaded from a snapshot from the old GUI. Added an input to CB and use it to filter components. The interpretation is simple: the input is split by the last dot, and the left part is considered a qualified name, and the right part is a function name written by the user so far. I rewrote the filtering algorithm designed by @jdunkerley, changing it a bit, so we support qualified names instead of just a type name. https://github.com/enso-org/enso/assets/3919101/76a957f6-e53f-49ad-996c-398cd7112fc6 # Important Notes * The component list is now sorted from "first to select" to "least interesting". The panel itself cares about putting the first on the bottom. * The suggestion db snapshot is very big, so it's instead loaded from external server.
This commit is contained in:
parent
3b790606e1
commit
c78291a153
284
app/gui2/package-lock.json
generated
284
app/gui2/package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"lib0": "^0.2.83",
|
||||
"pinia": "^2.1.6",
|
||||
"postcss-nesting": "^12.0.1",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vue": "^3.3.4",
|
||||
"ws": "^8.13.0",
|
||||
"y-protocols": "^1.0.5",
|
||||
@ -26,6 +27,7 @@
|
||||
"@tsconfig/node18": "^18.2.0",
|
||||
"@types/jsdom": "^21.1.1",
|
||||
"@types/node": "^18.17.5",
|
||||
"@types/shuffle-seed": "^1.1.0",
|
||||
"@types/ws": "^8.5.5",
|
||||
"@vitejs/plugin-vue": "^4.3.1",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
@ -37,6 +39,7 @@
|
||||
"jsdom": "^22.1.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.0.0",
|
||||
"shuffle-seed": "^1.1.6",
|
||||
"typescript": "~5.1.6",
|
||||
"vite": "^4.4.9",
|
||||
"vitest": "^0.34.2",
|
||||
@ -91,7 +94,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@ -107,7 +109,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@ -123,7 +124,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
@ -139,7 +139,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@ -155,7 +154,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@ -171,7 +169,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@ -187,7 +184,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
@ -203,7 +199,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@ -219,7 +214,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@ -235,7 +229,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@ -251,7 +244,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@ -267,7 +259,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@ -283,7 +274,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@ -299,7 +289,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@ -315,7 +304,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@ -331,7 +319,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@ -347,7 +334,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
@ -363,7 +349,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
@ -379,7 +364,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
@ -395,7 +379,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@ -411,7 +394,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@ -427,7 +409,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@ -622,6 +603,22 @@
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-virtual": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.1.tgz",
|
||||
"integrity": "sha512-fK8O0IL5+q+GrsMLuACVNk2x21g3yaw+sG2qn16SnUd3IlBsQyvWxLMGHmCmXRMecPjGRSZ/1LmZB4rjQm68og==",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^1.20.0||^2.0.0||^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rushstack/eslint-patch": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.3.3.tgz",
|
||||
@ -634,6 +631,197 @@
|
||||
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.84.tgz",
|
||||
"integrity": "sha512-UPKUiDwG7HOdPfOb1VFeEJ76JDgU2w80JLewzx6tb0fk9TIjhr9yxKBzPbzc/QpjGHDu5iaEuNeZcu27u4j63g==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@swc/types": "^0.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/swc"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-darwin-arm64": "1.3.84",
|
||||
"@swc/core-darwin-x64": "1.3.84",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.3.84",
|
||||
"@swc/core-linux-arm64-gnu": "1.3.84",
|
||||
"@swc/core-linux-arm64-musl": "1.3.84",
|
||||
"@swc/core-linux-x64-gnu": "1.3.84",
|
||||
"@swc/core-linux-x64-musl": "1.3.84",
|
||||
"@swc/core-win32-arm64-msvc": "1.3.84",
|
||||
"@swc/core-win32-ia32-msvc": "1.3.84",
|
||||
"@swc/core-win32-x64-msvc": "1.3.84"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@swc/helpers": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.84.tgz",
|
||||
"integrity": "sha512-mqK0buOo+toF2HoJ/gWj2ApZbvbIiNq3mMwSTHCYJHlQFQfoTWnl9aaD5GSO4wfNFVYfEZ1R259o5uv5NlVtoA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-x64": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.84.tgz",
|
||||
"integrity": "sha512-cyuQZz62C43EDZqtnptUTlfDvAjgG3qu139m5zsfIK6ltXA5inKFbDWV3a/M5c18dFzA2Xh21Q46XZezmtQ9Tg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.84.tgz",
|
||||
"integrity": "sha512-dmt/ECQrp3ZPWnK27p4E4xRIRHOoJhgGvxC5t5YaWzN20KcxE9ykEY2oLGSoeceM/A+4D11aRYGwF/EM7yOkvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.84.tgz",
|
||||
"integrity": "sha512-PgVfrI3NVg2z/oeg3GWLb9rFLMqidbdPwVH5nRyHVP2RX/BWP6qfnYfG+gJv4qrKzIldb9TyCGH7y8VWctKLxw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-musl": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.84.tgz",
|
||||
"integrity": "sha512-hcuEa8/vin4Ns0P+FpcDHQ4f3jmhgGKQhqw0w+TovPSVTIXr+nrFQ2AGhs9nAxS6tSQ77C53Eb5YRpK8ToFo1A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.84.tgz",
|
||||
"integrity": "sha512-IvyimSbwGdu21jBBEqR1Up8Jhvl8kIAf1k3e5Oy8oRfgojdUfmW1EIwgGdoUeyQ1VHlfquiWaRGfsnHQUKl35g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-musl": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.84.tgz",
|
||||
"integrity": "sha512-hdgVU/O5ufDCe+p5RtCjU7PRNwd0WM+eWJS+GNY4QWL6O8y2VLM+i4+6YzwSUjeBk0xd+1YElMxbqz7r5tSZhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.84.tgz",
|
||||
"integrity": "sha512-rzH6k2BF0BFOFhUTD+bh0oCiUCZjFfDfoZoYNN/CM0qbtjAcFH21hzMh/EH8ZaXq8k/iQmUNNa5MPNPZ4SOMNw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.84.tgz",
|
||||
"integrity": "sha512-Y+Dk7VLLVwwsAzoDmjkNW/sTmSPl9PGr4Mj1nhc5A2NNxZ+hz4SxFMclacDI03SC5ikK8Qh6WOoE/+nwUDa3uA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-x64-msvc": {
|
||||
"version": "1.3.84",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.84.tgz",
|
||||
"integrity": "sha512-WmpaosqCWMX7DArLdU8AJcj96hy0PKlYh1DaMVikSrrDHbJm2dZ8rd27IK3qUB8DgPkrDYHmLAKNZ+z3gWXgRQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/types": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.4.tgz",
|
||||
"integrity": "sha512-z/G02d+59gyyUb7KYhKi9jOhicek6QD2oMaotUyG+lUkybpXoV49dY9bj7Ah5Q+y7knK2jU67UTX9FyfGzaxQg=="
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
|
||||
@ -685,7 +873,7 @@
|
||||
"version": "18.17.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.9.tgz",
|
||||
"integrity": "sha512-fxaKquqYcPOGwE7tC1anJaPJ0GHyOVzfA2oUoXECjBjrtsIz4YJvtNYsq8LUcjEUehEF+jGpx8Z+lFrtT6z0tg==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.5.0",
|
||||
@ -693,6 +881,12 @@
|
||||
"integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/shuffle-seed": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/shuffle-seed/-/shuffle-seed-1.1.0.tgz",
|
||||
"integrity": "sha512-h6UW72XuE07bSDVTkNjMMapFj6ERJmvf+RajssOoEIVhuU53/+zyCSBjBrSpDJSzVYjGr4CYxW3ABrY0C6s8qA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
|
||||
@ -2220,7 +2414,6 @@
|
||||
"version": "0.18.20",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
|
||||
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
@ -2660,7 +2853,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -4933,7 +5125,6 @@
|
||||
"version": "3.28.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz",
|
||||
"integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
@ -5148,6 +5339,12 @@
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/seedrandom": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.4.tgz",
|
||||
"integrity": "sha512-9A+PDmgm+2du77B5i0Ip2cxOqqHjgNxnBgglxLcX78A2D6c2rTo61z4jnVABpF4cKeDMDG+cmXXvdnqse2VqMA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||
@ -5193,6 +5390,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/shuffle-seed": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/shuffle-seed/-/shuffle-seed-1.1.6.tgz",
|
||||
"integrity": "sha512-Vr9wlwMKJVUeFNGyc4aNbrzkI568gkve7ykyJ+1/cz78j3yRlJODWU0CuJ/fmk3MCjvAClpnqlycd/Y53UG3UA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"seedrandom": "^2.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
|
||||
@ -5736,6 +5942,18 @@
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/validate-npm-package-license": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
|
||||
@ -5750,7 +5968,6 @@
|
||||
"version": "4.4.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.4.9.tgz",
|
||||
"integrity": "sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.18.10",
|
||||
"postcss": "^8.4.27",
|
||||
@ -5824,6 +6041,19 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-top-level-await": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.3.1.tgz",
|
||||
"integrity": "sha512-55M1h4NAwkrpxPNOJIBzKZFihqLUzIgnElLSmPNPMR2Fn9+JHKaNg3sVX1Fq+VgvuBksQYxiD3OnwQAUu7kaPQ==",
|
||||
"dependencies": {
|
||||
"@rollup/plugin-virtual": "^3.0.1",
|
||||
"@swc/core": "^1.3.10",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": ">=2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "0.34.2",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.2.tgz",
|
||||
|
@ -20,6 +20,7 @@
|
||||
"lib0": "^0.2.83",
|
||||
"pinia": "^2.1.6",
|
||||
"postcss-nesting": "^12.0.1",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vue": "^3.3.4",
|
||||
"ws": "^8.13.0",
|
||||
"y-protocols": "^1.0.5",
|
||||
@ -33,6 +34,7 @@
|
||||
"@tsconfig/node18": "^18.2.0",
|
||||
"@types/jsdom": "^21.1.1",
|
||||
"@types/node": "^18.17.5",
|
||||
"@types/shuffle-seed": "^1.1.0",
|
||||
"@types/ws": "^8.5.5",
|
||||
"@vitejs/plugin-vue": "^4.3.1",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
@ -44,6 +46,7 @@
|
||||
"jsdom": "^22.1.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.0.0",
|
||||
"shuffle-seed": "^1.1.6",
|
||||
"typescript": "~5.1.6",
|
||||
"vite": "^4.4.9",
|
||||
"vitest": "^0.34.2",
|
||||
|
@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import ProjectView from '@/views/ProjectView.vue'
|
||||
|
||||
onMounted(() => useSuggestionDbStore().initializeDb())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -1,61 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver, useWindowEvent } from '@/util/events'
|
||||
import { useComponentsStore } from '@/stores/components'
|
||||
import type { Component } from '@/stores/components'
|
||||
import { useResizeObserver } from '@/util/events'
|
||||
import { type Component, makeComponentList } from '@/components/ComponentBrowser/component'
|
||||
import type { useNavigator } from '@/util/navigator'
|
||||
import { Vec2 } from '@/util/vec2'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||
import { useApproach } from '@/util/animation'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import { Filtering } from '@/components/ComponentBrowser/filtering'
|
||||
|
||||
const ITEM_SIZE = 32
|
||||
const TOP_BAR_HEIGHT = 32
|
||||
|
||||
const props = defineProps<{
|
||||
position: Vec2
|
||||
navigator: ReturnType<typeof useNavigator>
|
||||
}>()
|
||||
|
||||
// === Position ===
|
||||
const emit = defineEmits<{
|
||||
(e: 'finished'): void
|
||||
}>()
|
||||
|
||||
const shown = ref(false)
|
||||
const scenePosition = ref(Vec2.Zero())
|
||||
onMounted(() => {
|
||||
if (inputField.value != null) {
|
||||
inputField.value.focus({ preventScroll: true })
|
||||
selectLastAfterRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
// === Position ===
|
||||
|
||||
const transform = computed(() => {
|
||||
const nav = props.navigator
|
||||
const pos = scenePosition.value
|
||||
const pos = props.position
|
||||
return `${nav.transform} translate(${pos.x}px, ${pos.y}px) scale(${
|
||||
1 / nav.scale
|
||||
}) translateY(-100%)`
|
||||
})
|
||||
|
||||
function positionAtMouse(): boolean {
|
||||
const nav = props.navigator
|
||||
const mousePos = nav.sceneMousePos
|
||||
if (mousePos == null) return false
|
||||
scenePosition.value = mousePos
|
||||
return true
|
||||
// === Input and Filtering ===
|
||||
|
||||
const cbRoot = ref<HTMLElement>()
|
||||
const inputField = ref<HTMLElement>()
|
||||
const inputText = ref('')
|
||||
const filterFlags = ref({ showUnstable: false, showLocal: false })
|
||||
|
||||
const currentFiltering = computed(() => {
|
||||
const input = inputText.value
|
||||
const pathPatternSep = inputText.value.lastIndexOf('.')
|
||||
return new Filtering({
|
||||
pattern: input.substring(pathPatternSep + 1),
|
||||
qualifiedNamePattern: input.substring(0, pathPatternSep),
|
||||
...filterFlags.value,
|
||||
})
|
||||
})
|
||||
|
||||
watch(currentFiltering, selectLastAfterRefresh)
|
||||
|
||||
function handleDefocus(e: FocusEvent) {
|
||||
const stillInside =
|
||||
cbRoot.value != null &&
|
||||
e.relatedTarget instanceof Node &&
|
||||
cbRoot.value.contains(e.relatedTarget)
|
||||
if (stillInside) {
|
||||
if (inputField.value != null) {
|
||||
inputField.value.focus({ preventScroll: true })
|
||||
}
|
||||
} else {
|
||||
emit('finished')
|
||||
}
|
||||
}
|
||||
|
||||
// === Components List and Positions ===
|
||||
|
||||
const componentStore = useComponentsStore()
|
||||
const suggestionDbStore = useSuggestionDbStore()
|
||||
|
||||
const components = computed(() => {
|
||||
return makeComponentList(suggestionDbStore.entries, currentFiltering.value)
|
||||
})
|
||||
|
||||
const visibleComponents = computed(() => {
|
||||
if (scroller.value == null) return []
|
||||
const scrollPosition = animatedScrollPosition.value
|
||||
const firstVisible = componentAtY(scrollPosition)
|
||||
const lastVisible = componentAtY(animatedScrollPosition.value + scrollerSize.value.y)
|
||||
return componentStore.components.slice(firstVisible, lastVisible + 1).map((component, i) => {
|
||||
return { component, index: i + firstVisible }
|
||||
const topmostVisible = componentAtY(scrollPosition)
|
||||
const bottommostVisible = Math.max(
|
||||
0,
|
||||
componentAtY(animatedScrollPosition.value + scrollerSize.value.y),
|
||||
)
|
||||
return components.value.slice(bottommostVisible, topmostVisible + 1).map((component, i) => {
|
||||
return { component, index: i + bottommostVisible }
|
||||
})
|
||||
})
|
||||
|
||||
function componentPos(index: number) {
|
||||
return index * ITEM_SIZE
|
||||
return listContentHeight.value - (index + 1) * ITEM_SIZE
|
||||
}
|
||||
|
||||
function componentAtY(pos: number) {
|
||||
return Math.floor(pos / ITEM_SIZE)
|
||||
return Math.floor((listContentHeight.value - pos) / ITEM_SIZE)
|
||||
}
|
||||
|
||||
function componentStyle(index: number) {
|
||||
@ -63,7 +106,8 @@ function componentStyle(index: number) {
|
||||
}
|
||||
|
||||
function componentColor(component: Component): string {
|
||||
return componentStore.groups[component.group].color
|
||||
// TODO[ao]: A set of default color should be specified in css, see #7785.
|
||||
return suggestionDbStore.groups[component.group ?? -1]?.color ?? '#006b8a'
|
||||
}
|
||||
|
||||
// === Highlight ===
|
||||
@ -93,30 +137,35 @@ const highlightClipPath = computed(() => {
|
||||
return `inset(${top}px 0px ${bottom}px 0px round 16px)`
|
||||
})
|
||||
|
||||
function navigateFirst() {
|
||||
selected.value = 0
|
||||
scrollToSelected()
|
||||
}
|
||||
|
||||
function navigateLast() {
|
||||
selected.value = componentStore.components.length - 1
|
||||
scrollToSelected()
|
||||
}
|
||||
|
||||
function navigateUp() {
|
||||
if (selected.value != null && selected.value < components.value.length - 1) {
|
||||
selected.value += 1
|
||||
}
|
||||
scrollToSelected()
|
||||
}
|
||||
|
||||
function navigateDown() {
|
||||
if (selected.value == null) {
|
||||
selected.value = componentStore.components.length - 1
|
||||
selected.value = components.value.length - 1
|
||||
} else if (selected.value > 0) {
|
||||
selected.value -= 1
|
||||
}
|
||||
scrollToSelected()
|
||||
}
|
||||
|
||||
function navigateDown() {
|
||||
if (selected.value != null && selected.value < componentStore.components.length - 1) {
|
||||
selected.value += 1
|
||||
}
|
||||
scrollToSelected()
|
||||
/**
|
||||
* Select the last element after updating component list.
|
||||
*
|
||||
* As the list changes the scroller's content, we need to wait a frame so the scroller
|
||||
* recalculates its height and setting scrollTop will work properly.
|
||||
*/
|
||||
function selectLastAfterRefresh() {
|
||||
selected.value = 0
|
||||
nextTick(() => {
|
||||
scrollToSelected()
|
||||
animatedScrollPosition.skip()
|
||||
animatedHighlightPosition.skip()
|
||||
})
|
||||
}
|
||||
|
||||
// === Scrolling ===
|
||||
@ -126,12 +175,16 @@ const scrollerSize = useResizeObserver(scroller)
|
||||
const scrollPosition = ref(0)
|
||||
const animatedScrollPosition = useApproach(scrollPosition)
|
||||
|
||||
const listContentHeight = computed(() => componentStore.components.length * ITEM_SIZE)
|
||||
const listContentHeight = computed(() =>
|
||||
// We add a top padding of TOP_BAR_HEIGHT / 2 - otherwise the topmost entry would be covered
|
||||
// by top bar.
|
||||
Math.max(components.value.length * ITEM_SIZE + TOP_BAR_HEIGHT / 2, scrollerSize.value.y),
|
||||
)
|
||||
const listContentHeightPx = computed(() => `${listContentHeight.value}px`)
|
||||
|
||||
function scrollToSelected() {
|
||||
if (selectedPosition.value == null) return
|
||||
scrollPosition.value = selectedPosition.value - scrollerSize.value.y + ITEM_SIZE
|
||||
scrollPosition.value = Math.max(selectedPosition.value - scrollerSize.value.y + ITEM_SIZE, 0)
|
||||
}
|
||||
|
||||
function updateScroll() {
|
||||
@ -147,25 +200,11 @@ const docsVisible = ref(true)
|
||||
|
||||
// === Key Events Handler ===
|
||||
|
||||
useWindowEvent('keydown', (e) => {
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
if (!shown.value && positionAtMouse()) {
|
||||
shown.value = true
|
||||
selected.value = componentStore.components.length - 1
|
||||
nextTick(() => {
|
||||
scrollToSelected()
|
||||
animatedScrollPosition.skip()
|
||||
animatedHighlightPosition.skip()
|
||||
// After showing, the scroll top is set to 0 despite having assigned `scrollTop.prop` in
|
||||
// the template. We need to manually assign it.
|
||||
if (scroller.value) {
|
||||
scroller.value.scrollTop = animatedScrollPosition.value
|
||||
}
|
||||
})
|
||||
} else {
|
||||
shown.value = false
|
||||
}
|
||||
e.stopPropagation()
|
||||
emit('finished')
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
@ -175,80 +214,77 @@ useWindowEvent('keydown', (e) => {
|
||||
e.preventDefault()
|
||||
navigateDown()
|
||||
break
|
||||
case 'Home':
|
||||
e.preventDefault()
|
||||
navigateFirst()
|
||||
break
|
||||
case 'End':
|
||||
e.preventDefault()
|
||||
navigateLast()
|
||||
break
|
||||
case 'Escape':
|
||||
console.log('ESC')
|
||||
e.preventDefault()
|
||||
selected.value = null
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="shown"
|
||||
ref="cbRoot"
|
||||
class="ComponentBrowser"
|
||||
:style="{ transform, '--list-height': listContentHeightPx }"
|
||||
tabindex="-1"
|
||||
@focusout="handleDefocus"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div class="panel components">
|
||||
<div class="top-bar">
|
||||
<div class="top-bar-inner">
|
||||
<ToggleIcon icon="local_scope2" />
|
||||
<ToggleIcon icon="command_key3" />
|
||||
<ToggleIcon icon="unstable2" />
|
||||
<ToggleIcon icon="marketplace" />
|
||||
<ToggleIcon v-model="docsVisible" icon="right_side_panel" class="first-on-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="components-content">
|
||||
<div
|
||||
ref="scroller"
|
||||
class="list"
|
||||
:scrollTop.prop="animatedScrollPosition.value"
|
||||
@wheel.stop
|
||||
@scroll="updateScroll"
|
||||
>
|
||||
<div class="list-variant" style="">
|
||||
<div
|
||||
v-for="item in visibleComponents"
|
||||
:key="item.component.id"
|
||||
class="component"
|
||||
:style="componentStyle(item.index)"
|
||||
@mousemove="selected = item.index"
|
||||
>
|
||||
<SvgIcon
|
||||
:name="item.component.icon"
|
||||
:style="{ color: componentColor(item.component) }"
|
||||
/>
|
||||
{{ item.component.label }}
|
||||
</div>
|
||||
<div class="panels">
|
||||
<div class="panel components">
|
||||
<div class="top-bar">
|
||||
<div class="top-bar-inner">
|
||||
<ToggleIcon v-model="filterFlags.showLocal" icon="local_scope2" />
|
||||
<ToggleIcon icon="command_key3" />
|
||||
<ToggleIcon v-model="filterFlags.showUnstable" icon="unstable2" />
|
||||
<ToggleIcon icon="marketplace" />
|
||||
<ToggleIcon v-model="docsVisible" icon="right_side_panel" class="first-on-right" />
|
||||
</div>
|
||||
<div class="list-variant selected" :style="{ clipPath: highlightClipPath }">
|
||||
<div
|
||||
v-for="item in visibleComponents"
|
||||
:key="item.component.id"
|
||||
class="component"
|
||||
:style="{
|
||||
backgroundColor: componentColor(item.component),
|
||||
...componentStyle(item.index),
|
||||
}"
|
||||
>
|
||||
<SvgIcon :name="item.component.icon" />
|
||||
{{ item.component.label }}
|
||||
</div>
|
||||
<div class="components-content">
|
||||
<div
|
||||
ref="scroller"
|
||||
class="list"
|
||||
:scrollTop.prop="animatedScrollPosition.value"
|
||||
@wheel.stop.passive
|
||||
@scroll="updateScroll"
|
||||
>
|
||||
<div class="list-variant" style="">
|
||||
<div
|
||||
v-for="item in visibleComponents"
|
||||
:key="item.component.suggestionId"
|
||||
class="component"
|
||||
:style="componentStyle(item.index)"
|
||||
@mousemove="selected = item.index"
|
||||
>
|
||||
<SvgIcon
|
||||
:name="item.component.icon"
|
||||
:style="{ color: componentColor(item.component) }"
|
||||
/>
|
||||
{{ item.component.label }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-variant selected" :style="{ clipPath: highlightClipPath }">
|
||||
<div
|
||||
v-for="item in visibleComponents"
|
||||
:key="item.component.suggestionId"
|
||||
class="component"
|
||||
:style="{
|
||||
backgroundColor: componentColor(item.component),
|
||||
...componentStyle(item.index),
|
||||
}"
|
||||
>
|
||||
<SvgIcon :name="item.component.icon" />
|
||||
{{ item.component.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel docs" :class="{ hidden: !docsVisible }">DOCS</div>
|
||||
</div>
|
||||
<div class="panel docs" :class="{ hidden: !docsVisible }">DOCS</div>
|
||||
<div class="CBInput"><input ref="inputField" v-model="inputText" /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -257,6 +293,13 @@ useWindowEvent('keydown', (e) => {
|
||||
--list-height: 0px;
|
||||
width: fit-content;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
font-size: 11.5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.panels {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
@ -292,10 +335,11 @@ useWindowEvent('keydown', (e) => {
|
||||
}
|
||||
|
||||
.list {
|
||||
top: 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: calc(100% - 20px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
font-size: 11.5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@ -315,6 +359,7 @@ useWindowEvent('keydown', (e) => {
|
||||
padding: 9px;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
line-height: 1;
|
||||
}
|
||||
.selected {
|
||||
color: white;
|
||||
@ -359,4 +404,22 @@ useWindowEvent('keydown', (e) => {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.CBInput {
|
||||
border-radius: 20px;
|
||||
background-color: #eaeaea;
|
||||
height: 40px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
& input {
|
||||
border: none;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
background: none;
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -0,0 +1,84 @@
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
makeCon,
|
||||
makeMethod,
|
||||
makeModule,
|
||||
makeModuleMethod,
|
||||
makeStaticMethod,
|
||||
} from '@/stores/suggestionDatabase/entry'
|
||||
import {
|
||||
compareSuggestions,
|
||||
labelOfEntry,
|
||||
type MatchedSuggestion,
|
||||
} from '@/components/ComponentBrowser/component'
|
||||
import { Filtering } from '../filtering'
|
||||
import shuffleSeed from 'shuffle-seed'
|
||||
|
||||
test.each([
|
||||
[makeModuleMethod('Standard.Base.Data.read'), 'Data.read'],
|
||||
[makeStaticMethod('Standard.Base.Data.Vector.new'), 'Vector.new'],
|
||||
[makeMethod('Standard.Base.Data.Vector.get'), 'get'],
|
||||
[makeCon('Standard.Table.Data.Join_Kind.Join_Kind.LeftInner'), 'Join_Kind.LeftInner'],
|
||||
[makeModule('Standard.Table.Data.Join_Kind'), 'Join_Kind'],
|
||||
[makeModule('Standard.Table.Data'), 'Data', 'Standard.Table.Data'],
|
||||
[makeModuleMethod('local.Project.main'), 'Project.main'],
|
||||
])("$name Component's label is valid", (suggestion, expected, mainExpected?) => {
|
||||
const mainView = new Filtering({})
|
||||
const filteredView = new Filtering({ pattern: 'e' })
|
||||
expect(labelOfEntry(suggestion, filteredView)).toBe(expected)
|
||||
expect(labelOfEntry(suggestion, mainView)).toBe(mainExpected ?? expected)
|
||||
})
|
||||
|
||||
test('Suggestions are ordered properly', () => {
|
||||
const sortedEntries: MatchedSuggestion[] = [
|
||||
{
|
||||
id: 100,
|
||||
entry: makeModuleMethod('local.Project.Z.best_score'),
|
||||
match: { score: 0 },
|
||||
},
|
||||
{
|
||||
id: 90,
|
||||
entry: { ...makeModuleMethod('local.Project.Z.b'), groupIndex: 0 },
|
||||
match: { score: 50 },
|
||||
},
|
||||
{
|
||||
id: 91,
|
||||
entry: { ...makeModuleMethod('local.Project.Z.a'), groupIndex: 0 },
|
||||
match: { score: 50 },
|
||||
},
|
||||
{
|
||||
id: 89,
|
||||
entry: { ...makeModuleMethod('local.Project.A.foo'), groupIndex: 1 },
|
||||
match: { score: 50 },
|
||||
},
|
||||
{
|
||||
id: 88,
|
||||
entry: { ...makeModuleMethod('local.Project.B.another_module'), groupIndex: 1 },
|
||||
match: { score: 50 },
|
||||
},
|
||||
{
|
||||
id: 87,
|
||||
entry: { ...makeModule('local.Project.A'), groupIndex: 1 },
|
||||
match: { score: 50 },
|
||||
},
|
||||
{
|
||||
id: 50,
|
||||
entry: makeModuleMethod('local.Project.Z.module_content'),
|
||||
match: { score: 50 },
|
||||
},
|
||||
{
|
||||
id: 49,
|
||||
entry: makeModule('local.Project.Z.Module'),
|
||||
match: { score: 50 },
|
||||
},
|
||||
]
|
||||
const expectedOrdering = Array.from(sortedEntries, (entry) => entry.id)
|
||||
for (let i = 100; i < 120; i++) {
|
||||
const entries = shuffleSeed.shuffle(sortedEntries, i)
|
||||
|
||||
entries.sort(compareSuggestions)
|
||||
const result = Array.from(entries, (entry) => entry.id)
|
||||
expect(result).toStrictEqual(expectedOrdering)
|
||||
}
|
||||
})
|
@ -0,0 +1,228 @@
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
makeCon,
|
||||
makeFunction,
|
||||
makeLocal,
|
||||
makeMethod,
|
||||
makeModule,
|
||||
makeModuleMethod,
|
||||
makeStaticMethod,
|
||||
makeType,
|
||||
} from '@/stores/suggestionDatabase/entry'
|
||||
import { Filtering } from '../filtering'
|
||||
import type { QualifiedName } from '@/util/qualifiedName'
|
||||
|
||||
test.each([
|
||||
{ ...makeModuleMethod('Standard.Base.Data.read'), groupIndex: 0 },
|
||||
{ ...makeModuleMethod('Standard.Base.Data.write'), groupIndex: 0 },
|
||||
{ ...makeStaticMethod('Standard.Base.Data.Vector.Vector.new'), groupIndex: 1 },
|
||||
makeModule('local.New_Project'),
|
||||
makeModule('Standard.Base.Data'),
|
||||
])('$name entry is in the CB main view', (entry) => {
|
||||
const filtering = new Filtering({})
|
||||
expect(filtering.filter(entry)).not.toBeNull()
|
||||
})
|
||||
|
||||
test.each([
|
||||
makeModuleMethod('Standard.Base.Data.convert'), // not in group
|
||||
{ ...makeMethod('Standard.Base.Data.Vector.Vector.get'), groupIndex: 1 }, // not static method
|
||||
makeModule('Standard.Base.Data.Vector'), // Not top module
|
||||
])('$name entry is not in the CB main view', (entry) => {
|
||||
const filtering = new Filtering({})
|
||||
expect(filtering.filter(entry)).toBeNull()
|
||||
})
|
||||
|
||||
test.each([
|
||||
makeModuleMethod('local.Project.Module.module_method'),
|
||||
makeType('local.Project.Module.Type'),
|
||||
makeCon('local.Project.Module.Type.Con'),
|
||||
makeStaticMethod('local.Project.Module.Type.method'),
|
||||
makeModule('local.Project.Module.Submodule'),
|
||||
makeModuleMethod('another.Project.Local.Project.Module.module_method_with_matching_suffix'),
|
||||
])('$name entry is in the local.Project.Module content', (entry) => {
|
||||
const filtering = new Filtering({ qualifiedNamePattern: 'local.Project.Module' })
|
||||
const substringFiltering = new Filtering({ qualifiedNamePattern: 'local.Proj.Mod' })
|
||||
expect(filtering.filter(entry)).not.toBeNull()
|
||||
expect(substringFiltering.filter(entry)).not.toBeNull()
|
||||
})
|
||||
|
||||
test.each([
|
||||
makeModuleMethod('local.Project.Another_Module.another_module_method'),
|
||||
makeModuleMethod('local.Project.Another_Module.Module.another_module_with_same_name_method'),
|
||||
makeModuleMethod('local.Project.Module.Submodule.submodules_method'),
|
||||
makeModule('local.Project.Module'),
|
||||
makeModule('local.Project.Module.Submodule.Nested'),
|
||||
makeType('local.Project.In_Parent_Module'),
|
||||
makeType('local.Project.Module.Submodule.In_Submodule'),
|
||||
])('$name entry is not in the local.Project.Module content', (entry) => {
|
||||
const filtering = new Filtering({ qualifiedNamePattern: 'local.Project.Module' })
|
||||
const substringFiltering = new Filtering({ qualifiedNamePattern: 'local.Proj.Mod' })
|
||||
expect(filtering.filter(entry)).toBeNull()
|
||||
expect(substringFiltering.filter(entry)).toBeNull()
|
||||
})
|
||||
|
||||
test.each([
|
||||
makeModuleMethod('local.Project.Module.foo'),
|
||||
makeModuleMethod('local.Project.Module.Submodule.foo_in_submodule'),
|
||||
makeModuleMethod('local.Project.Module.Submodule.Nested.foo_nested'),
|
||||
makeType('local.Project.Module.Submodule.Nested.Foo_Type'),
|
||||
makeCon('local.Project.Module.Submodule.Nested.Foo_Type.Foo_Con'),
|
||||
makeStaticMethod('local.Project.Module.Submodule.Nested.Foo_Type.foo_method'),
|
||||
makeModule('local.Project.Module.Foo_Direct_Submodule'),
|
||||
makeModule('local.Project.Module.Submodule.Foo_Nested'),
|
||||
makeModuleMethod('another.Project.Local.Project.Module.foo_with_matching_suffix'),
|
||||
])(
|
||||
"$name entry is in the local.Project.Module content when filtering by pattern 'foo'",
|
||||
(entry) => {
|
||||
const filtering = new Filtering({
|
||||
pattern: 'foo',
|
||||
qualifiedNamePattern: 'local.Project.Module',
|
||||
})
|
||||
expect(filtering.filter(entry)).not.toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
makeModuleMethod('local.Project.Module.bar'),
|
||||
makeModuleMethod('local.Project.Another_Module.foo_in_another_module'),
|
||||
makeModuleMethod('local.Project.Another_Module.Module.foo_in_another_module_with_same_name'),
|
||||
makeModuleMethod('local.Project.foo_in_parent_module'),
|
||||
])(
|
||||
"$name entry is in not the local.Project.Module content when filtering by pattern 'foo'",
|
||||
(entry) => {
|
||||
const filtering = new Filtering({
|
||||
pattern: 'foo',
|
||||
qualifiedNamePattern: 'local.Project.Module',
|
||||
})
|
||||
expect(filtering.filter(entry)).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
makeStaticMethod('local.Project.Module.Type.foo_method'),
|
||||
makeCon('local.Project.Module.Type.Foo_Con'),
|
||||
{
|
||||
...makeStaticMethod('local.Project.Module.Type.foo_extension'),
|
||||
definedIn: 'local.Project.Another_Module' as QualifiedName,
|
||||
},
|
||||
])('$name entry is in the local.Project.Module.Type content', (entry) => {
|
||||
const filtering = new Filtering({ qualifiedNamePattern: 'local.Project.Module.Type' })
|
||||
const filteringWithPattern = new Filtering({
|
||||
pattern: 'foo',
|
||||
qualifiedNamePattern: 'local.Project.Module.Type',
|
||||
})
|
||||
expect(filtering.filter(entry)).not.toBeNull()
|
||||
expect(filteringWithPattern.filter(entry)).not.toBeNull()
|
||||
})
|
||||
|
||||
test.each([
|
||||
makeType('local.Project.Module.Type'),
|
||||
makeModuleMethod('local.Project.Module.module_method'),
|
||||
makeStaticMethod('local.Project.Module.Another_Type.another_type_method'),
|
||||
makeStaticMethod('local.Project.Another_Module.Type.another_module_type_method'),
|
||||
])('$name entry is not in the local.Project.Module.Type content', (entry) => {
|
||||
const filtering = new Filtering({ qualifiedNamePattern: 'local.Project.Module.Type' })
|
||||
expect(filtering.filter(entry)).toBeNull()
|
||||
})
|
||||
|
||||
test('An Instance method is shown when self type matches', () => {
|
||||
const entry = makeMethod('Standard.Base.Data.Vector.Vector.get')
|
||||
const filteringWithSelfType = new Filtering({
|
||||
selfType: 'Standard.Base.Data.Vector.Vector' as QualifiedName,
|
||||
})
|
||||
expect(filteringWithSelfType.filter(entry)).not.toBeNull()
|
||||
const filteringWithoutSelfType = new Filtering({ pattern: 'get' })
|
||||
expect(filteringWithoutSelfType.filter(entry)).toBeNull()
|
||||
})
|
||||
|
||||
test.each([
|
||||
makeModule('Standard.Base.Data.Vector'),
|
||||
makeStaticMethod('Standard.Base.Data.Vector.Vector.new'),
|
||||
makeCon('Standard.Base.Data.Vector.Vector.Vector_Con'),
|
||||
makeLocal('Standard.Base.Data.Vector', 'get'),
|
||||
makeFunction('Standard.Base.Data.Vector', 'func'),
|
||||
makeMethod('Standard.Base.Data.Vector.Vecto.get'),
|
||||
makeMethod('Standard.Base.Data.Vector.Vector2.get'),
|
||||
])('$name is filtered out when Vector self type is specified', (entry) => {
|
||||
const filtering = new Filtering({
|
||||
selfType: 'Standard.Base.Data.Vector.Vector' as QualifiedName,
|
||||
})
|
||||
expect(filtering.filter(entry)).toBeNull()
|
||||
})
|
||||
|
||||
test.each(['bar', 'barfoo', 'fo', 'bar_fo_bar'])("%s is not matched by pattern 'foo'", (name) => {
|
||||
const pattern = 'foo'
|
||||
const entry = makeModuleMethod(`local.Project.${name}`)
|
||||
const filtering = new Filtering({ pattern })
|
||||
expect(filtering.filter(entry)).toBeNull()
|
||||
})
|
||||
|
||||
test('Matching pattern without underscores', () => {
|
||||
const pattern = 'foo'
|
||||
const filtering = new Filtering({ pattern })
|
||||
const matchedSorted = [
|
||||
{ name: 'foo' }, // exact match
|
||||
{ name: 'foobar' }, // name start match
|
||||
{ name: 'bar', aliases: ['baz', 'foo'] }, // exact alias match
|
||||
{ name: 'bar', aliases: ['bazbar', 'foobar'] }, // alias start match
|
||||
{ name: 'bar_foo' }, // name word exact match
|
||||
{ name: 'baz_foobar' }, // name word start match
|
||||
{ name: 'bar', aliases: ['bar_foo'] }, // alias word exact match
|
||||
{ name: 'bar', aliases: ['baz_foobar'] }, // alias word start match
|
||||
{ name: 'frequent_objective_objections' }, // initials match
|
||||
{ name: 'bar', aliases: ['frequent_objective_objections'] }, // alias initials match
|
||||
]
|
||||
const matchResults = Array.from(matchedSorted, ({ name, aliases }) => {
|
||||
const entry = { ...makeModuleMethod(`local.Project.${name}`), aliases: aliases ?? [] }
|
||||
return filtering.filter(entry)
|
||||
})
|
||||
expect(matchResults[0]).not.toBeNull()
|
||||
for (let i = 1; i < matchResults.length; i++) {
|
||||
expect(matchResults[i]).not.toBeNull()
|
||||
expect(matchResults[i]?.score).toBeGreaterThan(matchResults[i - 1]?.score ?? Infinity)
|
||||
}
|
||||
})
|
||||
|
||||
test('Matching pattern with underscores', () => {
|
||||
const pattern = 'foo_bar'
|
||||
const filtering = new Filtering({ pattern })
|
||||
const matchedSorted = [
|
||||
{ name: 'foo_bar' }, // exact match
|
||||
{ name: 'foo_xyz_barabc' }, // first word exact match
|
||||
{ name: 'fooabc_barabc' }, // first word match
|
||||
{ name: 'bar', aliases: ['foo_bar', 'foo'] }, // exact alias match
|
||||
{ name: 'bar', aliases: ['foo', 'foo_xyz_barabc'] }, // alias first word exact match
|
||||
{ name: 'bar', aliases: ['foo', 'fooabc_barabc'] }, // alias first word match
|
||||
{ name: 'xyz_foo_abc_bar_xyz' }, // exact word match
|
||||
{ name: 'xyz_fooabc_abc_barabc_xyz' }, // non-exact word match
|
||||
{ name: 'bar', aliases: ['xyz_foo_abc_bar_xyz'] }, // alias word exact match
|
||||
{ name: 'bar', aliases: ['xyz_fooabc_abc_barabc_xyz'] }, // alias word start match
|
||||
]
|
||||
const matchResults = Array.from(matchedSorted, ({ name, aliases }) => {
|
||||
const entry = { ...makeModuleMethod(`local.Project.${name}`), aliases: aliases ?? [] }
|
||||
return filtering.filter(entry)
|
||||
})
|
||||
expect(matchResults[0]).not.toBeNull()
|
||||
for (let i = 1; i < matchResults.length; i++) {
|
||||
expect(matchResults[i]).not.toBeNull()
|
||||
expect(matchResults[i]?.score).toBeGreaterThan(matchResults[i - 1]?.score ?? Infinity)
|
||||
}
|
||||
})
|
||||
|
||||
test('Unstable filtering', () => {
|
||||
const stableEntry = makeStaticMethod('local.Project.Type.stable')
|
||||
const unstableEntry = {
|
||||
...makeStaticMethod('local.Project.Type.unstable'),
|
||||
isUnstable: true,
|
||||
}
|
||||
const stableFiltering = new Filtering({ qualifiedNamePattern: 'local.Project.Type' })
|
||||
expect(stableFiltering.filter(stableEntry)).not.toBeNull()
|
||||
expect(stableFiltering.filter(unstableEntry)).toBeNull()
|
||||
const unstableFiltering = new Filtering({
|
||||
qualifiedNamePattern: 'local.Project.Type',
|
||||
showUnstable: true,
|
||||
})
|
||||
expect(unstableFiltering.filter(stableEntry)).not.toBeNull()
|
||||
expect(unstableFiltering.filter(unstableEntry)).not.toBeNull()
|
||||
})
|
67
app/gui2/src/components/ComponentBrowser/component.ts
Normal file
67
app/gui2/src/components/ComponentBrowser/component.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import {
|
||||
SuggestionKind,
|
||||
type SuggestionEntry,
|
||||
type SuggestionId,
|
||||
} from '@/stores/suggestionDatabase/entry'
|
||||
import { SuggestionDb } from '@/stores/suggestionDatabase'
|
||||
import { Filtering, type MatchResult } from './filtering'
|
||||
import { qnIsTopElement, qnLastSegment } from '@/util/qualifiedName'
|
||||
import { compareOpt } from '@/util/compare'
|
||||
import { isSome } from '@/util/opt'
|
||||
|
||||
export interface Component {
|
||||
suggestionId: SuggestionId
|
||||
icon: string
|
||||
label: string
|
||||
match: MatchResult
|
||||
group?: number
|
||||
}
|
||||
|
||||
export function labelOfEntry(entry: SuggestionEntry, filtering: Filtering) {
|
||||
const isTopModule = entry.kind == SuggestionKind.Module && qnIsTopElement(entry.definedIn)
|
||||
if (filtering.isMainView() && isTopModule) return entry.definedIn
|
||||
else if (entry.memberOf && entry.selfType == null)
|
||||
return `${qnLastSegment(entry.memberOf)}.${entry.name}`
|
||||
else return entry.name
|
||||
}
|
||||
|
||||
export interface MatchedSuggestion {
|
||||
id: SuggestionId
|
||||
entry: SuggestionEntry
|
||||
match: MatchResult
|
||||
}
|
||||
|
||||
export function compareSuggestions(a: MatchedSuggestion, b: MatchedSuggestion): number {
|
||||
const matchCompare = a.match.score - b.match.score
|
||||
if (matchCompare !== 0) return matchCompare
|
||||
const groupCompare = compareOpt(a.entry.groupIndex, b.entry.groupIndex, 1)
|
||||
if (groupCompare !== 0) return groupCompare
|
||||
const kindCompare =
|
||||
+(a.entry.kind === SuggestionKind.Module) - +(b.entry.kind === SuggestionKind.Module)
|
||||
if (kindCompare !== 0) return kindCompare
|
||||
const moduleCompare = a.entry.definedIn.localeCompare(b.entry.definedIn)
|
||||
if (moduleCompare !== 0) return moduleCompare
|
||||
return a.id - b.id
|
||||
}
|
||||
|
||||
export function makeComponentList(db: SuggestionDb, filtering: Filtering): Component[] {
|
||||
function* matchSuggestions() {
|
||||
for (const [id, entry] of db.entries()) {
|
||||
const match = filtering.filter(entry)
|
||||
if (isSome(match)) {
|
||||
yield { id, entry, match }
|
||||
}
|
||||
}
|
||||
}
|
||||
const matched: MatchedSuggestion[] = Array.from(matchSuggestions())
|
||||
matched.sort(compareSuggestions)
|
||||
return Array.from(matched, ({ id, entry, match }) => {
|
||||
return {
|
||||
suggestionId: id,
|
||||
icon: entry.iconName ?? 'marketplace',
|
||||
label: labelOfEntry(entry, filtering),
|
||||
match,
|
||||
group: entry.groupIndex,
|
||||
}
|
||||
})
|
||||
}
|
243
app/gui2/src/components/ComponentBrowser/filtering.ts
Normal file
243
app/gui2/src/components/ComponentBrowser/filtering.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import { SuggestionKind, type SuggestionEntry } from '@/stores/suggestionDatabase/entry'
|
||||
import type { Opt } from '@/util/opt'
|
||||
import { qnParent, type QualifiedName } from '@/util/qualifiedName'
|
||||
|
||||
export interface Filter {
|
||||
pattern?: string
|
||||
selfType?: QualifiedName
|
||||
qualifiedNamePattern?: string
|
||||
showUnstable?: boolean
|
||||
showLocal?: boolean
|
||||
}
|
||||
|
||||
export enum MatchTypeScore {
|
||||
NameWordMatchFirst = 0,
|
||||
AliasWordMatchFirst = 1000,
|
||||
NameWordMatch = 2000,
|
||||
AliasWordMatch = 3000,
|
||||
NameInitialMatch = 4000,
|
||||
AliasInitialMatch = 5000,
|
||||
}
|
||||
|
||||
export type MatchResult = {
|
||||
matchedAlias?: string
|
||||
score: number
|
||||
}
|
||||
|
||||
class FilteringWithPattern {
|
||||
pattern: string
|
||||
wordMatchRegex: RegExp
|
||||
initialsMatchRegex?: RegExp
|
||||
|
||||
constructor(pattern: string) {
|
||||
this.pattern = pattern
|
||||
// Each word in pattern should try to match a beginning of a word in the name. Each matched
|
||||
// word is put to regex group - this is used to compute score (details in matchedWordsScore
|
||||
// method). See `Filtering` docs for full algorithm description.
|
||||
this.wordMatchRegex = new RegExp(
|
||||
'(?:^|_)(' + pattern.replace(/_/g, '[^_]*).*?_(') + '[^_]*).*',
|
||||
'i',
|
||||
)
|
||||
if (pattern.length > 1 && pattern.indexOf('_') < 0) {
|
||||
// Similar to wordMatchRegex, but each letter in pattern is considered a word (and we don't
|
||||
// specify any groups).
|
||||
this.initialsMatchRegex = new RegExp('(^|_)' + pattern.split('').join('.*_'), 'i')
|
||||
}
|
||||
}
|
||||
|
||||
private matchedWordsScore(
|
||||
matchType: MatchTypeScore,
|
||||
matchedString: string,
|
||||
words: RegExpExecArray,
|
||||
): number {
|
||||
words.shift()
|
||||
const matchedWords = words.join('_')
|
||||
const nonexactMatchPenalty = this.pattern === matchedString ? 0 : 50
|
||||
const nonexactWordMatchPenalty = Math.floor(
|
||||
((matchedWords.length - this.pattern.length) * 50) / matchedWords.length,
|
||||
)
|
||||
return matchType + nonexactMatchPenalty + nonexactWordMatchPenalty
|
||||
}
|
||||
|
||||
private firstMatchingAlias(entry: SuggestionEntry) {
|
||||
for (const alias of entry.aliases) {
|
||||
const match = this.wordMatchRegex.exec(alias)
|
||||
if (match != null) return { alias, match }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
tryMatch(entry: SuggestionEntry): Opt<MatchResult> {
|
||||
const nameWordsMatch = this.wordMatchRegex?.exec(entry.name)
|
||||
if (nameWordsMatch?.index === 0) {
|
||||
return {
|
||||
score: this.matchedWordsScore(
|
||||
MatchTypeScore.NameWordMatchFirst,
|
||||
entry.name,
|
||||
nameWordsMatch,
|
||||
),
|
||||
}
|
||||
}
|
||||
const matchedAlias = this.firstMatchingAlias(entry)
|
||||
if (matchedAlias?.match.index === 0) {
|
||||
return {
|
||||
matchedAlias: matchedAlias.alias,
|
||||
score: this.matchedWordsScore(
|
||||
MatchTypeScore.AliasWordMatchFirst,
|
||||
matchedAlias.alias,
|
||||
matchedAlias.match,
|
||||
),
|
||||
}
|
||||
}
|
||||
if (nameWordsMatch) {
|
||||
return {
|
||||
score: this.matchedWordsScore(MatchTypeScore.NameWordMatch, entry.name, nameWordsMatch),
|
||||
}
|
||||
}
|
||||
if (matchedAlias) {
|
||||
return {
|
||||
matchedAlias: matchedAlias.alias,
|
||||
score: this.matchedWordsScore(
|
||||
MatchTypeScore.AliasWordMatch,
|
||||
matchedAlias.alias,
|
||||
matchedAlias.match,
|
||||
),
|
||||
}
|
||||
}
|
||||
if (this.initialsMatchRegex) {
|
||||
if (this.initialsMatchRegex.test(entry.name)) {
|
||||
return { score: MatchTypeScore.NameInitialMatch }
|
||||
}
|
||||
const matchedAliasInitials = entry.aliases.find((alias) =>
|
||||
this.initialsMatchRegex?.test(alias),
|
||||
)
|
||||
if (matchedAliasInitials) {
|
||||
return { matchedAlias: matchedAliasInitials, score: MatchTypeScore.AliasInitialMatch }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class FilteringQualifiedName {
|
||||
pattern: string
|
||||
memberRegex: RegExp
|
||||
memberOfAnyDescendantRegex: RegExp
|
||||
|
||||
constructor(pattern: string) {
|
||||
this.pattern = pattern
|
||||
// Starting at some segment, each segment should start with the respective
|
||||
// pattern's segment. See `Filtering` docs for full algorithm description.
|
||||
const segmentsMatch = '(^|\\.)' + pattern.replace(/\./g, '[^\\.]*\\.')
|
||||
// The direct members must have no more segments in their path.
|
||||
this.memberRegex = new RegExp(segmentsMatch + '[^\\.]*$', 'i')
|
||||
this.memberOfAnyDescendantRegex = new RegExp(segmentsMatch, 'i')
|
||||
}
|
||||
|
||||
matches(entry: SuggestionEntry, alsoFilteringByPattern: boolean): boolean {
|
||||
const entryOwner =
|
||||
entry.kind == SuggestionKind.Module ? qnParent(entry.definedIn) : entry.definedIn
|
||||
const regex = alsoFilteringByPattern ? this.memberOfAnyDescendantRegex : this.memberRegex
|
||||
return (
|
||||
(entryOwner != null && regex.test(entryOwner)) ||
|
||||
(entry.memberOf != null && regex.test(entry.memberOf))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtering Suggestions for Component Browser.
|
||||
*
|
||||
* A single entry is filtered in if _all_ conditions below are met:
|
||||
*
|
||||
* - The private entries never matches.
|
||||
*
|
||||
* - If `selfType` is specified, only entries of methods taking a value of this type as self
|
||||
* argument are accepted. Static methods, and methods of other types are filtered out.
|
||||
*
|
||||
* - If `qualifiedNamePattern` is specified, only entries being a content of a module or type
|
||||
* matching the pattern are accepted. If `pattern` is also specified (see below), the content
|
||||
* of any descendant of the module is included too. The module/type qualified name matches
|
||||
* a pattern with `n` segments, if its last `n` segments starts with the respective pattern's
|
||||
* segments. For example 'local.Project.Main' is matched by 'Project.Main' or 'l.Proj.M'
|
||||
* patterns.
|
||||
*
|
||||
* - Without `showUnstable` flag, unstable entries will be filtered out.
|
||||
*
|
||||
* - 'showLocal' flag is not implemented yet.
|
||||
*
|
||||
* - Finally, if `pattern` is specified, the entry name or any alias must match the pattern:
|
||||
* there must exists a subsequence of words in name/alias (words are separated by `_`), so each
|
||||
* word:
|
||||
* - starts with respective word in the pattern,
|
||||
* - or starts with respective _letter_ in the pattern (initials match).
|
||||
* For example `foo_bar_baz` name is matched by patterns `foo`, `bar`, `f_b` or `ba_ba`,
|
||||
* `fbb` or `bb`.
|
||||
*
|
||||
* For more examples, see various matching/not matching test cases in `__tests__/filtering.test.ts`
|
||||
*
|
||||
* When matched, a matching score is computed; the lower the score, the better is match. The exact
|
||||
* matches are the best, matching first word is preferred over matching other words, and matching
|
||||
* name is preferred before alias. See `FilteringWithPattern.tryMatch` implementation for details.
|
||||
*/
|
||||
export class Filtering {
|
||||
pattern?: FilteringWithPattern
|
||||
selfType?: QualifiedName
|
||||
qualifiedName?: FilteringQualifiedName
|
||||
showUnstable: boolean = false
|
||||
showLocal: boolean = false
|
||||
|
||||
constructor(filter: Filter) {
|
||||
const { pattern, selfType, qualifiedNamePattern, showUnstable, showLocal } = filter
|
||||
if (pattern != null && pattern !== '') {
|
||||
this.pattern = new FilteringWithPattern(pattern)
|
||||
}
|
||||
this.selfType = selfType
|
||||
if (qualifiedNamePattern != null && qualifiedNamePattern !== '') {
|
||||
this.qualifiedName = new FilteringQualifiedName(qualifiedNamePattern)
|
||||
}
|
||||
this.showUnstable = showUnstable ?? false
|
||||
this.showLocal = showLocal ?? false
|
||||
}
|
||||
|
||||
private selfTypeMatches(entry: SuggestionEntry): boolean {
|
||||
if (this.selfType == null) {
|
||||
return entry.selfType == null
|
||||
} else {
|
||||
return entry.selfType === this.selfType
|
||||
}
|
||||
}
|
||||
|
||||
private qualifiedNameMatches(entry: SuggestionEntry): boolean {
|
||||
if (this.qualifiedName == null) return true
|
||||
return this.qualifiedName.matches(entry, this.pattern != null)
|
||||
}
|
||||
|
||||
isMainView() {
|
||||
return (
|
||||
this.pattern == null && this.selfType == null && this.qualifiedName == null && !this.showLocal
|
||||
)
|
||||
}
|
||||
|
||||
private mainViewFilter(entry: SuggestionEntry) {
|
||||
const hasGroup = entry.groupIndex != null
|
||||
const isModule = entry.kind === SuggestionKind.Module
|
||||
const isTopElement = (entry.definedIn.match(/\./g)?.length ?? 0) <= 2
|
||||
if (hasGroup || (isModule && isTopElement)) {
|
||||
return { score: 0 }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
filter(entry: SuggestionEntry): Opt<MatchResult> {
|
||||
if (entry.isPrivate) return null
|
||||
else if (!this.selfTypeMatches(entry)) return null
|
||||
else if (!this.qualifiedNameMatches(entry)) return null
|
||||
else if (!this.showUnstable && entry.isUnstable) return null
|
||||
else if (this.pattern) return this.pattern.tryMatch(entry)
|
||||
else if (this.isMainView()) return this.mainViewFilter(entry)
|
||||
else return { score: 0 }
|
||||
}
|
||||
}
|
@ -19,6 +19,8 @@ const mode = ref('design')
|
||||
const viewportNode = ref<HTMLElement>()
|
||||
const navigator = useNavigator(viewportNode)
|
||||
const graphStore = useGraphStore()
|
||||
const componentBrowserVisible = ref(false)
|
||||
const componentBrowserPosition = ref(Vec2.Zero())
|
||||
|
||||
watchEffect(() => {
|
||||
console.log(`execution mode changed to '${mode.value}'.`)
|
||||
@ -48,12 +50,18 @@ function keyboardBusy() {
|
||||
return document.activeElement != document.body
|
||||
}
|
||||
|
||||
useWindowEvent('keypress', (e) => {
|
||||
useWindowEvent('keydown', (e) => {
|
||||
if (keyboardBusy()) return
|
||||
const pos = navigator.sceneMousePos
|
||||
if (pos == null) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
if (!componentBrowserVisible.value) {
|
||||
componentBrowserPosition.value = pos
|
||||
componentBrowserVisible.value = true
|
||||
}
|
||||
break
|
||||
case 'n': {
|
||||
const n = graphStore.createNode(pos)
|
||||
if (n == null) return
|
||||
@ -99,7 +107,12 @@ function moveNode(id: ExprId, delta: Vec2) {
|
||||
@movePosition="moveNode(id, $event)"
|
||||
/>
|
||||
</div>
|
||||
<ComponentBrowser :navigator="navigator" />
|
||||
<ComponentBrowser
|
||||
v-if="componentBrowserVisible"
|
||||
:navigator="navigator"
|
||||
:position="componentBrowserPosition"
|
||||
@finished="componentBrowserVisible = false"
|
||||
/>
|
||||
<TopBar
|
||||
v-model:mode="mode"
|
||||
:title="title"
|
||||
|
@ -18,6 +18,8 @@ const props = defineProps<{ name: string }>()
|
||||
<style scoped>
|
||||
svg {
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import HelloWorld from '../HelloWorld.vue'
|
||||
|
||||
describe('HelloWorld', () => {
|
||||
it('renders properly', () => {
|
||||
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
|
||||
expect(wrapper.text()).toContain('Hello Vitest')
|
||||
})
|
||||
})
|
@ -1,39 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface Component {
|
||||
id: number
|
||||
icon: string
|
||||
label: string
|
||||
score: number
|
||||
group: number
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
color: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export const useComponentsStore = defineStore('components', () => {
|
||||
function* generate(sets: number) {
|
||||
for (let i = 0; i < sets; i += 10) {
|
||||
yield { id: i, icon: 'data_input', label: 'Data.read', score: 0.0, group: 0 }
|
||||
yield { id: i + 1, icon: 'cloud_from', label: 'Data.fetch', score: 0.0, group: 0 }
|
||||
yield { id: i + 2, icon: 'google', label: 'Query.google', score: 0.0, group: 0 }
|
||||
yield { id: i + 3, icon: 'chat_gpt_mod2', label: 'Query.chat_gpt', score: 0.0, group: 0 }
|
||||
yield { id: i + 4, icon: 'number_input', label: 'number', score: 0.0, group: 1 }
|
||||
yield { id: i + 5, icon: 'text_input', label: 'text', score: 0.0, group: 1 }
|
||||
yield { id: i + 6, icon: 'table_edit', label: 'Table.new', score: 0.0, group: 1 }
|
||||
yield { id: i + 7, icon: 'array_new2', label: 'Array.new', score: 0.0, group: 1 }
|
||||
yield { id: i + 8, icon: 'calendar', label: 'Date.current', score: 0.0, group: 2 }
|
||||
yield { id: i + 9, icon: 'time', label: 'Date.current_time', score: 0.0, group: 2 }
|
||||
}
|
||||
}
|
||||
const components = ref<Array<Component>>([...generate(300)].reverse())
|
||||
const groups = ref<Array<Group>>([
|
||||
{ color: '#4D9A29', name: 'Data Input' },
|
||||
{ color: '#B37923', name: 'Input' },
|
||||
{ color: '#9735B9', name: 'Time' },
|
||||
])
|
||||
return { components, groups }
|
||||
})
|
199
app/gui2/src/stores/suggestionDatabase/entry.ts
Normal file
199
app/gui2/src/stores/suggestionDatabase/entry.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { assert } from '@/util/assert'
|
||||
import {
|
||||
qnLastSegment,
|
||||
qnParent,
|
||||
type Identifier,
|
||||
type QualifiedName,
|
||||
qnSplit,
|
||||
isQualifiedName,
|
||||
isIdentifier,
|
||||
} from '@/util/qualifiedName'
|
||||
|
||||
export type SuggestionId = number
|
||||
|
||||
export type UUID = string
|
||||
|
||||
// The kind of a suggestion.
|
||||
export enum SuggestionKind {
|
||||
Module = 'Module',
|
||||
Type = 'Type',
|
||||
Constructor = 'Constructor',
|
||||
Method = 'Method',
|
||||
Function = 'Function',
|
||||
Local = 'Local',
|
||||
}
|
||||
|
||||
// The argument of a constructor, method or function suggestion.
|
||||
export interface SuggestionEntryArgument {
|
||||
/** The argument name. */
|
||||
name: string
|
||||
/** The argument type. String 'Any' is used to specify generic types. */
|
||||
type: string
|
||||
/** Indicates whether the argument is lazy. */
|
||||
isSuspended: boolean
|
||||
/** Indicates whether the argument has default value. */
|
||||
hasDefault: boolean
|
||||
/** Optional default value. */
|
||||
defaultValue?: string
|
||||
/** Optional list of possible values that this argument takes. */
|
||||
tagValues?: string[]
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
/**
|
||||
* Line position in a document (zero-based).
|
||||
*/
|
||||
line: number
|
||||
|
||||
/**
|
||||
* Character offset on a line in a document (zero-based). Assuming that the
|
||||
* line is represented as a string, the `character` value represents the gap
|
||||
* between the `character` and `character + 1`.
|
||||
*
|
||||
* If the character value is greater than the line length it defaults back to
|
||||
* the line length.
|
||||
*/
|
||||
character: number
|
||||
}
|
||||
|
||||
// The definition scope
|
||||
export interface SuggestionEntryScope {
|
||||
// The start position of the definition scope
|
||||
start: Position
|
||||
// The end position of the definition scope
|
||||
end: Position
|
||||
}
|
||||
|
||||
export interface SuggestionEntry {
|
||||
kind: SuggestionKind
|
||||
/// A module where the suggested object is defined.
|
||||
definedIn: QualifiedName
|
||||
/// A type or module this method or constructor belongs to.
|
||||
memberOf?: QualifiedName
|
||||
isPrivate: boolean
|
||||
isUnstable: boolean
|
||||
/// A name of suggested object.
|
||||
name: Identifier
|
||||
/// A list of aliases.
|
||||
aliases: string[]
|
||||
/// A type of the "self" argument. This field is present only for instance methods.
|
||||
selfType?: QualifiedName
|
||||
/// Argument lists of suggested object (atom or function). If the object does not take any
|
||||
/// arguments, the list is empty.
|
||||
arguments: SuggestionEntryArgument[]
|
||||
/// A type returned by the suggested object.
|
||||
returnType: QualifiedName
|
||||
/// A module reexporting this entity.
|
||||
reexportedIn?: QualifiedName
|
||||
/// A list of documentation sections associated with object.
|
||||
documentation: string
|
||||
/// A scope where this suggestion is visible.
|
||||
scope?: SuggestionEntryScope
|
||||
/// A name of a custom icon to use when displaying the entry.
|
||||
iconName?: string
|
||||
/// A name of a group this entry belongs to.
|
||||
groupIndex?: number
|
||||
}
|
||||
|
||||
function makeSimpleEntry(
|
||||
kind: SuggestionKind,
|
||||
definedIn: QualifiedName,
|
||||
name: Identifier,
|
||||
returnType: QualifiedName,
|
||||
): SuggestionEntry {
|
||||
return {
|
||||
kind,
|
||||
definedIn,
|
||||
name,
|
||||
isPrivate: false,
|
||||
isUnstable: false,
|
||||
aliases: [],
|
||||
arguments: [],
|
||||
returnType,
|
||||
documentation: '',
|
||||
}
|
||||
}
|
||||
|
||||
export function makeModule(fqn: string): SuggestionEntry {
|
||||
assert(isQualifiedName(fqn))
|
||||
return makeSimpleEntry(SuggestionKind.Module, fqn, qnLastSegment(fqn), fqn)
|
||||
}
|
||||
|
||||
export function makeType(fqn: string): SuggestionEntry {
|
||||
assert(isQualifiedName(fqn))
|
||||
const [definedIn, name] = qnSplit(fqn)
|
||||
assert(definedIn != null)
|
||||
return makeSimpleEntry(SuggestionKind.Type, definedIn, name, fqn)
|
||||
}
|
||||
|
||||
export function makeCon(fqn: string): SuggestionEntry {
|
||||
assert(isQualifiedName(fqn))
|
||||
const [type, name] = qnSplit(fqn)
|
||||
assert(type != null)
|
||||
const definedIn = qnParent(type)
|
||||
assert(definedIn != null)
|
||||
return {
|
||||
memberOf: type,
|
||||
...makeSimpleEntry(SuggestionKind.Constructor, definedIn, name, type),
|
||||
}
|
||||
}
|
||||
|
||||
export function makeMethod(fqn: string, returnType: string = 'Any'): SuggestionEntry {
|
||||
assert(isQualifiedName(fqn))
|
||||
assert(isQualifiedName(returnType))
|
||||
const [type, name] = qnSplit(fqn)
|
||||
assert(type != null)
|
||||
const definedIn = qnParent(type)
|
||||
assert(definedIn != null)
|
||||
return {
|
||||
memberOf: type,
|
||||
selfType: type,
|
||||
...makeSimpleEntry(SuggestionKind.Method, definedIn, name, returnType),
|
||||
}
|
||||
}
|
||||
|
||||
export function makeStaticMethod(fqn: string, returnType: string = 'Any'): SuggestionEntry {
|
||||
assert(isQualifiedName(fqn))
|
||||
assert(isQualifiedName(returnType))
|
||||
const [type, name] = qnSplit(fqn)
|
||||
assert(type != null)
|
||||
const definedIn = qnParent(type)
|
||||
assert(definedIn != null)
|
||||
return {
|
||||
memberOf: type,
|
||||
...makeSimpleEntry(SuggestionKind.Method, definedIn, name, returnType),
|
||||
}
|
||||
}
|
||||
|
||||
export function makeModuleMethod(fqn: string, returnType: string = 'Any'): SuggestionEntry {
|
||||
assert(isQualifiedName(fqn))
|
||||
assert(isQualifiedName(returnType))
|
||||
const [definedIn, name] = qnSplit(fqn)
|
||||
assert(definedIn != null)
|
||||
return {
|
||||
memberOf: definedIn,
|
||||
...makeSimpleEntry(SuggestionKind.Method, definedIn, name, returnType),
|
||||
}
|
||||
}
|
||||
|
||||
export function makeFunction(
|
||||
definedIn: string,
|
||||
name: string,
|
||||
returnType: string = 'Any',
|
||||
): SuggestionEntry {
|
||||
assert(isQualifiedName(definedIn))
|
||||
assert(isIdentifier(name))
|
||||
assert(isQualifiedName(returnType))
|
||||
return makeSimpleEntry(SuggestionKind.Function, definedIn, name, returnType)
|
||||
}
|
||||
|
||||
export function makeLocal(
|
||||
definedIn: string,
|
||||
name: string,
|
||||
returnType: string = 'Any',
|
||||
): SuggestionEntry {
|
||||
assert(isQualifiedName(definedIn))
|
||||
assert(isIdentifier(name))
|
||||
assert(isQualifiedName(returnType))
|
||||
return makeSimpleEntry(SuggestionKind.Local, definedIn, name, returnType)
|
||||
}
|
58
app/gui2/src/stores/suggestionDatabase/index.ts
Normal file
58
app/gui2/src/stores/suggestionDatabase/index.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { SuggestionKind, type SuggestionEntry, type SuggestionId } from './entry'
|
||||
import { isSome } from '@/util/opt'
|
||||
import { findIndexOpt } from '@/util/array'
|
||||
|
||||
export type SuggestionDb = Map<SuggestionId, SuggestionEntry>
|
||||
export const SuggestionDb = Map<SuggestionId, SuggestionEntry>
|
||||
|
||||
export interface Group {
|
||||
color: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function fromJson(data: any, groups: Group[]): SuggestionEntry {
|
||||
function tagValue(tag: string): string {
|
||||
return data.documentation.find((section: any) => section['Tag']?.tag === tag)?.Tag.body
|
||||
}
|
||||
return {
|
||||
kind: data.kind,
|
||||
definedIn: data.defined_in,
|
||||
memberOf: data.kind === SuggestionKind.Constructor ? data.return_type : data.self_type,
|
||||
selfType: !data.is_static ? data.self_type : null,
|
||||
isPrivate: isSome(tagValue('Private')),
|
||||
isUnstable: isSome(tagValue('Unstable')) || isSome(tagValue('Advanced')),
|
||||
name: data.name.content,
|
||||
aliases: Array.from(tagValue('Alias')?.split(',') ?? [], (alias) => alias.trim()),
|
||||
arguments: data.arguments,
|
||||
returnType: data.return_type,
|
||||
documentation: data.documentation,
|
||||
iconName: data.icon_name,
|
||||
groupIndex: findIndexOpt(groups, (group) => data.group_name == group.name) ?? undefined,
|
||||
reexportedIn: data.reexported_in,
|
||||
}
|
||||
}
|
||||
|
||||
export const useSuggestionDbStore = defineStore('suggestionDatabase', () => {
|
||||
const entries = reactive(new SuggestionDb())
|
||||
const groups = ref<Group[]>([
|
||||
{ color: '#4D9A29', name: 'Input' },
|
||||
{ color: '#B37923', name: 'Web' },
|
||||
{ color: '#9735B9', name: 'Parse' },
|
||||
{ color: '#4D9A29', name: 'Select' },
|
||||
{ color: '#B37923', name: 'Join' },
|
||||
{ color: '#9735B9', name: 'Transform' },
|
||||
{ color: '#4D9A29', name: 'Output' },
|
||||
])
|
||||
|
||||
async function initializeDb() {
|
||||
// TODO[ao]: This is a temporary mock; soon we should load db from the language server (#7785)
|
||||
const mockDb = await (await fetch('https://capricornus.pl/~adam/db-formatted.json')).json()
|
||||
for (const [id, entry] of Object.entries(mockDb)) {
|
||||
entries.set(+id, fromJson(entry, groups.value))
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, groups, initializeDb }
|
||||
})
|
@ -1,2 +1,8 @@
|
||||
/** An array that has at least one element present at all times. */
|
||||
export type NonEmptyArray<T> = [T, ...T[]]
|
||||
|
||||
/** An equivalent of `Array.prototype.findIndex` method, but returns null instead of -1. */
|
||||
export function findIndexOpt<T>(arr: T[], pred: (elem: T) => boolean): number | null {
|
||||
const index = arr.findIndex(pred)
|
||||
return index >= 0 ? index : null
|
||||
}
|
||||
|
@ -9,3 +9,15 @@ export function assert(condition: boolean): asserts condition {
|
||||
export function assertUnreachable(): never {
|
||||
throw new Error('Unreachable code')
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an error with provided message.
|
||||
*
|
||||
* It is convenient to use at the end of a nullable chain:
|
||||
* ```ts
|
||||
* const x = foo?.bar.baz?.() ?? bail('Expected foo.bar.baz to exist')
|
||||
* ```
|
||||
*/
|
||||
export function bail(message: string): never {
|
||||
throw new Error(message)
|
||||
}
|
||||
|
36
app/gui2/src/util/compare.ts
Normal file
36
app/gui2/src/util/compare.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { isNone, isSome, type Opt } from './opt'
|
||||
|
||||
/**
|
||||
* Compare two optional numbers. Returns a comparision result like specified for `sort`
|
||||
* comparators. None (null or undefined) values may be considered lesser or greater
|
||||
* than all others. Null and undefined are not discriminated - they are considered equal.
|
||||
*
|
||||
* @param a Left operand.
|
||||
* @param b Right operand.
|
||||
* @param noneValueCmp The result value in case where `a` is none and `b` is a number.
|
||||
* Positive value will make all nones greater than numbres, and negative will make
|
||||
* them lesser. Passing 0 is a Bad Idea™.
|
||||
* @returns negative if a < b, positive if a > b, and 0 if a == b.
|
||||
*/
|
||||
export function compareOpt(a: Opt<number>, b: Opt<number>, noneValueCmp: number = -1): number {
|
||||
if (isSome(a) && isSome(b)) {
|
||||
return a - b
|
||||
} else if (isNone(a) && isNone(b)) {
|
||||
return 0
|
||||
} else {
|
||||
return isNone(a) ? noneValueCmp : -noneValueCmp
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.vitest) {
|
||||
const { test, expect } = import.meta.vitest
|
||||
test.each([
|
||||
[1, 2, -1],
|
||||
[2, 1, 1],
|
||||
[1, 1, 0],
|
||||
[null, 1, -1],
|
||||
[1, null, 1],
|
||||
])('Compare %s with %s is %s', (a, b, expected) => {
|
||||
expect(compareOpt(a, b)).toBe(expected)
|
||||
})
|
||||
}
|
@ -13,15 +13,15 @@ import {
|
||||
import { Vec2 } from './vec2'
|
||||
|
||||
/**
|
||||
* Add an event listener on an {@link Element} for the duration of the component's lifetime.
|
||||
* Add an event listener on an {@link HTMLElement} for the duration of the component's lifetime.
|
||||
* @param target element on which to register the event
|
||||
* @param event name of event to register
|
||||
* @param handler event handler
|
||||
*/
|
||||
export function useElementEvent<K extends keyof ElementEventMap>(
|
||||
target: Element,
|
||||
export function useElementEvent<K extends keyof HTMLElementEventMap>(
|
||||
target: HTMLElement,
|
||||
event: K,
|
||||
handler: (e: ElementEventMap[K]) => void,
|
||||
handler: (e: HTMLElementEventMap[K]) => void,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): void {
|
||||
onMounted(() => {
|
||||
|
136
app/gui2/src/util/qualifiedName.ts
Normal file
136
app/gui2/src/util/qualifiedName.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import type { Opt } from './opt'
|
||||
|
||||
declare const identifierBrand: unique symbol
|
||||
declare const qualifiedNameBrand: unique symbol
|
||||
const identifierRegexPart = '(?:[a-zA-Z_][0-9]*)+'
|
||||
const identifierRegex = new RegExp(`^${identifierRegexPart}$`)
|
||||
const qnRegex = new RegExp(`^${identifierRegexPart}(?:\\.${identifierRegexPart})*$`)
|
||||
|
||||
/** A string representing a valid identifier of our language. */
|
||||
export type Identifier = string & { [identifierBrand]: never; [qualifiedNameBrand]: never }
|
||||
|
||||
export function isIdentifier(str: string): str is Identifier {
|
||||
return identifierRegex.test(str)
|
||||
}
|
||||
|
||||
export function tryIdentifier(str: string): Opt<Identifier> {
|
||||
return isIdentifier(str) ? str : null
|
||||
}
|
||||
|
||||
/** A string representing a valid qualified name of our language.
|
||||
*
|
||||
* In our language, the segments are separated by `.`, and its segments
|
||||
* must be a valid identifiers. In particular, a single identifier is
|
||||
* also a valid qualified name.
|
||||
*/
|
||||
export type QualifiedName = string & { [qualifiedNameBrand]: never }
|
||||
|
||||
export function isQualifiedName(str: string): str is QualifiedName {
|
||||
return qnRegex.test(str)
|
||||
}
|
||||
|
||||
export function tryQualifiedName(str: string): Opt<QualifiedName> {
|
||||
return isQualifiedName(str) ? str : null
|
||||
}
|
||||
|
||||
/** Split the qualified name to parent and last segment (name). */
|
||||
export function qnSplit(name: QualifiedName): [Opt<QualifiedName>, Identifier] {
|
||||
const separator = name.lastIndexOf('.')
|
||||
const parent = separator > 0 ? (name.substring(0, separator) as QualifiedName) : null
|
||||
const lastSegment = name.substring(separator + 1) as Identifier
|
||||
return [parent, lastSegment]
|
||||
}
|
||||
|
||||
/** Get the last segment of qualified name. */
|
||||
export function qnLastSegment(name: QualifiedName): Identifier {
|
||||
const separator = name.lastIndexOf('.')
|
||||
return name.substring(separator + 1) as Identifier
|
||||
}
|
||||
|
||||
/** Get the parent qualified name (without last segment) */
|
||||
export function qnParent(name: QualifiedName): Opt<QualifiedName> {
|
||||
const separator = name.lastIndexOf('.')
|
||||
return separator > 1 ? (name.substring(0, separator) as QualifiedName) : null
|
||||
}
|
||||
|
||||
export function qnJoin(left: QualifiedName, right: QualifiedName): QualifiedName {
|
||||
return `${left}.${right}` as QualifiedName
|
||||
}
|
||||
|
||||
/** Checks if given full qualified name is considered a top element of some project.
|
||||
*
|
||||
* The fully qualified names consists of namespace, project name, and then a path (possibly empty).
|
||||
* The element is considered a top element if there is max 1 segment in the path.
|
||||
*/
|
||||
export function qnIsTopElement(name: QualifiedName): boolean {
|
||||
return (name.match(/\./g)?.length ?? 0) <= 2
|
||||
}
|
||||
|
||||
if (import.meta.vitest) {
|
||||
const { test, expect } = import.meta.vitest
|
||||
|
||||
const validIdentifiers = [
|
||||
'A',
|
||||
'a',
|
||||
'_',
|
||||
'_A',
|
||||
'A_',
|
||||
'_1',
|
||||
'a_A',
|
||||
'abc',
|
||||
'Abc',
|
||||
'abC',
|
||||
'a1',
|
||||
'A10_70',
|
||||
]
|
||||
const invalidIdentifiers = ['', '1', '1Abc', '1_', 'abA!']
|
||||
|
||||
test.each(validIdentifiers)("'%s' is a valid identifier", (name) =>
|
||||
expect(tryIdentifier(name)).toStrictEqual(name as Identifier),
|
||||
)
|
||||
test.each(invalidIdentifiers)("'%s' is an invalid identifier", (name) =>
|
||||
expect(tryIdentifier(name)).toBeNull(),
|
||||
)
|
||||
|
||||
test.each(validIdentifiers.concat('A._', 'a19_r14.zz9z', 'a.b.c.d.e.F'))(
|
||||
"'%s' is a valid qualified name",
|
||||
(name) => expect(tryQualifiedName(name)).toStrictEqual(name as QualifiedName),
|
||||
)
|
||||
|
||||
test.each(invalidIdentifiers.concat('.Abc', 'Abc.', '.A.b.c', 'A.b.c.', 'A.B.8.D', '_.._'))(
|
||||
"'%s' is an invalid qualified name",
|
||||
(name) => expect(tryQualifiedName(name)).toBeNull(),
|
||||
)
|
||||
|
||||
test.each([
|
||||
['Name', null, 'Name'],
|
||||
['Parent.Name', 'Parent', 'Name'],
|
||||
['local.Project.Parent.Name', 'local.Project.Parent', 'Name'],
|
||||
])(
|
||||
"Qualified name '%s' parent is '%s' and the last segment is '%s'",
|
||||
(name, parent, lastSegment) => {
|
||||
const qn = tryQualifiedName(name)
|
||||
expect(qn).not.toBeNull()
|
||||
expect(qnLastSegment(qn!)).toBe(lastSegment)
|
||||
expect(qnParent(qn!)).toBe(parent)
|
||||
expect(qnSplit(qn!)).toStrictEqual([parent, lastSegment])
|
||||
if (parent != null) {
|
||||
const qnParent = tryQualifiedName(parent)
|
||||
const qnLastSegment = tryIdentifier(lastSegment)
|
||||
expect(qnParent).not.toBeNull()
|
||||
expect(qnLastSegment).not.toBeNull()
|
||||
expect(qnJoin(qnParent!, qnLastSegment!)).toBe(qn)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
['local.Project', true],
|
||||
['local.Project.elem', true],
|
||||
['local.Project.Module.elem', false],
|
||||
])('qnIsTopElement(%s) returns %s', (name, result) => {
|
||||
const qn = tryQualifiedName(name)
|
||||
expect(qn).not.toBeNull()
|
||||
expect(qnIsTopElement(name as QualifiedName)).toBe(result)
|
||||
})
|
||||
}
|
@ -1,12 +1,13 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "shared/**/*"],
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.json", "src/**/*.vue", "shared/**/*"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"types": ["vitest/importMeta"]
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,6 @@
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
"types": ["node", "jsdom", "vitest/importMeta"]
|
||||
}
|
||||
}
|
||||
|
@ -4,10 +4,11 @@ import { defineConfig, Plugin } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import postcssNesting from 'postcss-nesting'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import topLevelAwait from 'vite-plugin-top-level-await'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), yWebsocketServer()],
|
||||
plugins: [vue(), yWebsocketServer(), topLevelAwait()],
|
||||
resolve: {
|
||||
alias: {
|
||||
shared: fileURLToPath(new URL('./shared', import.meta.url)),
|
||||
|
@ -7,6 +7,7 @@ export default mergeConfig(
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
includeSource: ['./src/**/*.{ts,vue}'],
|
||||
exclude: [...configDefaults.exclude, 'e2e/*'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user