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:
Adam Obuchowicz 2023-09-20 11:16:18 +02:00 committed by GitHub
parent 3b790606e1
commit c78291a153
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1539 additions and 202 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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>

View File

@ -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>

View File

@ -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)
}
})

View File

@ -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()
})

View 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,
}
})
}

View 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 }
}
}

View File

@ -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"

View File

@ -18,6 +18,8 @@ const props = defineProps<{ name: string }>()
<style scoped>
svg {
width: 16px;
min-width: 16px;
height: 16px;
min-height: 16px;
}
</style>

View File

@ -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')
})
})

View File

@ -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 }
})

View 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)
}

View 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 }
})

View File

@ -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
}

View File

@ -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)
}

View 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)
})
}

View File

@ -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(() => {

View 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)
})
}

View File

@ -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"]
}
}

View File

@ -4,6 +4,6 @@
"compilerOptions": {
"composite": true,
"lib": [],
"types": ["node", "jsdom"]
"types": ["node", "jsdom", "vitest/importMeta"]
}
}

View File

@ -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)),

View File

@ -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)),
},