aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-12 19:54:46 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-08-12 19:54:46 +0200
commitd22c3b0c257f5daf5b401988a35ab9ce981a2341 (patch)
tree0a661c927c37b52197326d1c05e211daf9bd19e5 /subprojects
parentfix(language): rule parsing test (diff)
downloadrefinery-d22c3b0c257f5daf5b401988a35ab9ce981a2341.tar.gz
refinery-d22c3b0c257f5daf5b401988a35ab9ce981a2341.tar.zst
refinery-d22c3b0c257f5daf5b401988a35ab9ce981a2341.zip
refactor(frontend): move from Webpack to Vite
Also overhaulds the building and linting for frontend assets.
Diffstat (limited to 'subprojects')
-rw-r--r--subprojects/frontend/.eslintrc.cjs89
-rw-r--r--subprojects/frontend/.eslintrc.js39
-rw-r--r--subprojects/frontend/.stylelintrc.js15
-rw-r--r--subprojects/frontend/build.gradle73
-rw-r--r--subprojects/frontend/index.html (renamed from subprojects/frontend/src/index.html)1
-rw-r--r--subprojects/frontend/package.json85
-rw-r--r--subprojects/frontend/prettier.config.cjs5
-rw-r--r--subprojects/frontend/src/App.tsx33
-rw-r--r--subprojects/frontend/src/Loading.tsx19
-rw-r--r--subprojects/frontend/src/RootStore.tsx15
-rw-r--r--subprojects/frontend/src/editor/EditorArea.tsx41
-rw-r--r--subprojects/frontend/src/editor/EditorButtons.tsx32
-rw-r--r--subprojects/frontend/src/editor/EditorParent.ts74
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts81
-rw-r--r--subprojects/frontend/src/editor/GenerateButton.tsx10
-rw-r--r--subprojects/frontend/src/editor/defineDecorationSetExtension.ts (renamed from subprojects/frontend/src/editor/decorationSetExtension.ts)19
-rw-r--r--subprojects/frontend/src/editor/findOccurrences.ts13
-rw-r--r--subprojects/frontend/src/editor/semanticHighlighting.ts22
-rw-r--r--subprojects/frontend/src/global.d.ts11
-rw-r--r--subprojects/frontend/src/index.scss16
-rw-r--r--subprojects/frontend/src/index.tsx46
-rw-r--r--subprojects/frontend/src/language/folding.ts9
-rw-r--r--subprojects/frontend/src/language/indentation.ts19
-rw-r--r--subprojects/frontend/src/language/problem.grammar2
-rw-r--r--subprojects/frontend/src/language/problemLanguageSupport.ts11
-rw-r--r--subprojects/frontend/src/language/props.ts2
-rw-r--r--subprojects/frontend/src/theme/EditorTheme.ts46
-rw-r--r--subprojects/frontend/src/theme/ThemeProvider.tsx61
-rw-r--r--subprojects/frontend/src/theme/ThemeStore.ts64
-rw-r--r--subprojects/frontend/src/themeVariables.module.scss9
-rw-r--r--subprojects/frontend/src/themes.scss38
-rw-r--r--subprojects/frontend/src/utils/ConditionVariable.ts6
-rw-r--r--subprojects/frontend/src/utils/PendingTask.ts4
-rw-r--r--subprojects/frontend/src/utils/Timer.ts2
-rw-r--r--subprojects/frontend/src/utils/getLogger.ts (renamed from subprojects/frontend/src/utils/logger.ts)34
-rw-r--r--subprojects/frontend/src/xtext/ContentAssistService.ts86
-rw-r--r--subprojects/frontend/src/xtext/HighlightingService.ts7
-rw-r--r--subprojects/frontend/src/xtext/OccurrencesService.ts34
-rw-r--r--subprojects/frontend/src/xtext/UpdateService.ts69
-rw-r--r--subprojects/frontend/src/xtext/ValidationService.ts18
-rw-r--r--subprojects/frontend/src/xtext/XtextClient.ts53
-rw-r--r--subprojects/frontend/src/xtext/XtextWebSocketClient.ts94
-rw-r--r--subprojects/frontend/src/xtext/xtextMessages.ts30
-rw-r--r--subprojects/frontend/src/xtext/xtextServiceResults.ts92
-rw-r--r--subprojects/frontend/tsconfig.base.json (renamed from subprojects/frontend/tsconfig.sonar.json)13
-rw-r--r--subprojects/frontend/tsconfig.json23
-rw-r--r--subprojects/frontend/tsconfig.node.json17
-rw-r--r--subprojects/frontend/types/ImportMeta.d.ts9
-rw-r--r--subprojects/frontend/types/grammar.d.ts5
-rw-r--r--subprojects/frontend/types/node/@lezer-generator-rollup.d.ts7
-rw-r--r--subprojects/frontend/vite.config.ts92
-rw-r--r--subprojects/frontend/webpack.config.js164
-rw-r--r--subprojects/language-web/build.gradle4
53 files changed, 962 insertions, 901 deletions
diff --git a/subprojects/frontend/.eslintrc.cjs b/subprojects/frontend/.eslintrc.cjs
new file mode 100644
index 00000000..e6be4d65
--- /dev/null
+++ b/subprojects/frontend/.eslintrc.cjs
@@ -0,0 +1,89 @@
1const path = require('node:path');
2
3// Allow the Codium ESLint plugin to find `tsconfig.json` from the repository root.
4const project = [
5 path.join(__dirname, 'tsconfig.json'),
6 path.join(__dirname, 'tsconfig.node.json'),
7];
8
9/** @type {import('eslint').Linter.Config} */
10module.exports = {
11 plugins: ['@typescript-eslint'],
12 extends: [
13 'airbnb',
14 'airbnb-typescript',
15 'airbnb/hooks',
16 'plugin:@typescript-eslint/recommended',
17 'plugin:@typescript-eslint/recommended-requiring-type-checking',
18 'plugin:prettier/recommended',
19 ],
20 parserOptions: {
21 project,
22 sourceType: 'module',
23 },
24 parser: '@typescript-eslint/parser',
25 settings: {
26 'import/parsers': {
27 '@typescript-eslint/parser': ['.ts', '.tsx'],
28 },
29 'import/resolver': {
30 typescript: {
31 alwaysTryTypes: true,
32 project,
33 },
34 },
35 },
36 env: {
37 browser: true,
38 },
39 ignorePatterns: ['build/**/*'],
40 rules: {
41 // In typescript, some class methods implementing an inderface do not use `this`:
42 // https://github.com/typescript-eslint/typescript-eslint/issues/1103
43 'class-methods-use-this': 'off',
44 // Make sure every import can be resolved by `eslint-import-resolver-typescript`.
45 'import/no-unresolved': 'error',
46 // Organize imports automatically.
47 'import/order': [
48 'error',
49 {
50 alphabetize: {
51 order: 'asc',
52 },
53 'newlines-between': 'always',
54 },
55 ],
56 },
57 overrides: [
58 {
59 files: ['types/**/*.d.ts'],
60 rules: {
61 // We don't have control over exports of external modules.
62 'import/prefer-default-export': 'off',
63 },
64 },
65 {
66 files: ['*.cjs'],
67 rules: {
68 // https://github.com/typescript-eslint/typescript-eslint/issues/1724
69 '@typescript-eslint/no-var-requires': 'off',
70 },
71 },
72 {
73 files: ['.eslintrc.cjs', 'prettier.config.cjs', 'vite.config.ts'],
74 env: {
75 browser: false,
76 node: true,
77 },
78 rules: {
79 // Allow devDependencies in configuration files.
80 'import/no-extraneous-dependencies': [
81 'error',
82 { devDependencies: true },
83 ],
84 // Access to the environment in configuration files.
85 'no-process-env': 'off',
86 },
87 },
88 ],
89};
diff --git a/subprojects/frontend/.eslintrc.js b/subprojects/frontend/.eslintrc.js
deleted file mode 100644
index aa7636f8..00000000
--- a/subprojects/frontend/.eslintrc.js
+++ /dev/null
@@ -1,39 +0,0 @@
1// Loosely based on
2// https://github.com/iamturns/create-exposed-app/blob/f14e435b8ce179c89cce3eea89e56202153a53da/.eslintrc.js
3module.exports = {
4 plugins: [
5 '@typescript-eslint',
6 ],
7 extends: [
8 'airbnb',
9 'airbnb-typescript',
10 'airbnb/hooks',
11 'plugin:@typescript-eslint/recommended',
12 'plugin:@typescript-eslint/recommended-requiring-type-checking',
13 ],
14 parserOptions: {
15 project: './tsconfig.json',
16 },
17 rules: {
18 // https://basarat.gitbooks.io/typescript/docs/tips/defaultIsBad.html
19 'import/prefer-default-export': 'off',
20 'import/no-default-export': 'error',
21 // propTypes are for runtime validation, but we rely on TypeScript for build-time validation:
22 // https://github.com/yannickcr/eslint-plugin-react/issues/2275#issuecomment-492003857
23 'react/prop-types': 'off',
24 // Make sure switches are exhaustive: https://stackoverflow.com/a/60166264
25 'default-case': 'off',
26 '@typescript-eslint/switch-exhaustiveness-check': 'error',
27 // https://github.com/airbnb/javascript/pull/2501
28 'react/function-component-definition': ['error', {
29 namedComponents: 'function-declaration',
30 }],
31 },
32 env: {
33 browser: true,
34 },
35 ignorePatterns: [
36 '*.js',
37 'build/**/*',
38 ],
39};
diff --git a/subprojects/frontend/.stylelintrc.js b/subprojects/frontend/.stylelintrc.js
deleted file mode 100644
index 7adf8f26..00000000
--- a/subprojects/frontend/.stylelintrc.js
+++ /dev/null
@@ -1,15 +0,0 @@
1module.exports = {
2 extends: 'stylelint-config-recommended-scss',
3 // Simplified for only :export to TypeScript based on
4 // https://github.com/pascalduez/stylelint-config-css-modules/blob/d792a6ac7d2bce8239edccbc5a72e0616f22d696/index.js
5 rules: {
6 'selector-pseudo-class-no-unknown': [
7 true,
8 {
9 ignorePseudoClasses: [
10 'export',
11 ],
12 },
13 ],
14 },
15};
diff --git a/subprojects/frontend/build.gradle b/subprojects/frontend/build.gradle
index 71444e89..5ed90c31 100644
--- a/subprojects/frontend/build.gradle
+++ b/subprojects/frontend/build.gradle
@@ -5,11 +5,11 @@ plugins {
5 5
6import org.siouan.frontendgradleplugin.infrastructure.gradle.RunYarn 6import org.siouan.frontendgradleplugin.infrastructure.gradle.RunYarn
7 7
8def webpackOutputDir = "${buildDir}/webpack" 8def viteOutputDir = "${buildDir}/vite"
9def productionResources = file("${webpackOutputDir}/production") 9def productionResources = file("${viteOutputDir}/production")
10 10
11frontend { 11frontend {
12 assembleScript = 'assemble:webpack' 12 assembleScript = 'run build'
13} 13}
14 14
15configurations { 15configurations {
@@ -21,23 +21,10 @@ configurations {
21 21
22def installFrontend = tasks.named('installFrontend') 22def installFrontend = tasks.named('installFrontend')
23 23
24def generateLezerGrammar = tasks.register('generateLezerGrammar', RunYarn) {
25 dependsOn installFrontend
26 inputs.file 'src/language/problem.grammar'
27 inputs.file 'package.json'
28 inputs.file rootProject.file('yarn.lock')
29 outputs.file "${buildDir}/generated/sources/lezer/problem.ts"
30 outputs.file "${buildDir}/generated/sources/lezer/problem.terms.ts"
31 script = 'run assemble:lezer'
32}
33
34def assembleFrontend = tasks.named('assembleFrontend') 24def assembleFrontend = tasks.named('assembleFrontend')
35assembleFrontend.configure { 25assembleFrontend.configure {
36 dependsOn generateLezerGrammar
37 inputs.dir 'src' 26 inputs.dir 'src'
38 inputs.file "${buildDir}/generated/sources/lezer/problem.ts" 27 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'vite.config.ts')
39 inputs.file "${buildDir}/generated/sources/lezer/problem.terms.ts"
40 inputs.files('package.json', 'webpack.config.js')
41 inputs.file rootProject.file('yarn.lock') 28 inputs.file rootProject.file('yarn.lock')
42 outputs.dir productionResources 29 outputs.dir productionResources
43} 30}
@@ -48,54 +35,62 @@ artifacts {
48 } 35 }
49} 36}
50 37
51def eslint = tasks.register('eslint', RunYarn) { 38def typecheckFrontend = tasks.register('typecheckFrontend', RunYarn) {
52 dependsOn installFrontend 39 dependsOn installFrontend
53 inputs.dir 'src' 40 inputs.dir 'src'
54 inputs.files('.eslintrc.js', 'tsconfig.json') 41 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json')
42 inputs.file rootProject.file('yarn.lock')
43 outputs.dir "${buildDir}/typescript"
44 script = 'run typecheck'
45 group = 'verification'
46 description = 'Check for TypeScript type errors.'
47}
48
49def lintFrontend = tasks.register('lintFrontend', RunYarn) {
50 dependsOn installFrontend
51 inputs.dir 'src'
52 inputs.files('.eslintrc.cjs', 'prettier.config.cjs')
53 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json')
55 inputs.file rootProject.file('yarn.lock') 54 inputs.file rootProject.file('yarn.lock')
56 if (project.hasProperty('ci')) { 55 if (project.hasProperty('ci')) {
57 outputs.file "${buildDir}/eslint.json" 56 outputs.file "${buildDir}/eslint.json"
58 script = 'run check:eslint:ci' 57 script = 'run lint:ci'
59 } else { 58 } else {
60 script = 'run check:eslint' 59 script = 'run lint'
61 } 60 }
62 group = 'verification' 61 group = 'verification'
63 description = 'Check for TypeScript errors.' 62 description = 'Check for TypeScript lint errors and warnings.'
64} 63}
65 64
66def stylelint = tasks.register('stylelint', RunYarn) { 65def prettier = tasks.register('fixFrontend', RunYarn) {
67 dependsOn installFrontend 66 dependsOn installFrontend
68 inputs.dir 'src' 67 inputs.dir 'src'
69 inputs.file '.stylelintrc.js' 68 inputs.files('.eslintrc.cjs', 'prettier.config.cjs')
69 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'tsconfig.node.json')
70 inputs.file rootProject.file('yarn.lock') 70 inputs.file rootProject.file('yarn.lock')
71 if (project.hasProperty('ci')) { 71 script = 'run lint:fix'
72 outputs.file "${buildDir}/stylelint.json"
73 script = 'run check:stylelint:ci'
74 } else {
75 script = 'run check:stylelint'
76 }
77 group = 'verification' 72 group = 'verification'
78 description = 'Check for Sass errors.' 73 description = 'Fix TypeScript lint errors and warnings.'
79} 74}
80 75
81tasks.named('check') { 76tasks.named('check') {
82 dependsOn(eslint, stylelint) 77 dependsOn(typecheckFrontend)
78 dependsOn(lintFrontend)
83} 79}
84 80
85tasks.register('webpackServe', RunYarn) { 81tasks.register('serveFrontend', RunYarn) {
86 dependsOn installFrontend 82 dependsOn installFrontend
87 dependsOn generateLezerGrammar 83 inputs.dir 'src'
88 outputs.dir "${webpackOutputDir}/development" 84 inputs.files('package.json', 'tsconfig.json', 'tsconfig.base.json', 'vite.config.ts')
85 inputs.file rootProject.file('yarn.lock')
86 outputs.dir "${viteOutputDir}/development"
89 script = 'run serve' 87 script = 'run serve'
90 group = 'run' 88 group = 'run'
91 description = 'Start a Webpack dev server with hot module replacement.' 89 description = 'Start a Vite dev server with hot module replacement.'
92} 90}
93 91
94sonarqube.properties { 92sonarqube.properties {
95 properties['sonar.sources'] += ['src'] 93 properties['sonar.sources'] += ['src']
96 property 'sonar.nodejs.executable', "${frontend.nodeInstallDirectory.get()}/bin/node" 94 property 'sonar.nodejs.executable', "${frontend.nodeInstallDirectory.get()}/bin/node"
97 property 'sonar.eslint.reportPaths', "${buildDir}/eslint.json" 95 property 'sonar.eslint.reportPaths', "${buildDir}/eslint.json"
98 property 'sonar.css.stylelint.reportPaths', "${buildDir}/stylelint.json"
99 // SonarJS does not pick up typescript files with `exactOptionalPropertyTypes`
100 property 'sonar.typescript.tsconfigPath', 'tsconfig.sonar.json'
101} 96}
diff --git a/subprojects/frontend/src/index.html b/subprojects/frontend/index.html
index f404aa8a..999e69a3 100644
--- a/subprojects/frontend/src/index.html
+++ b/subprojects/frontend/index.html
@@ -12,5 +12,6 @@
12 </p> 12 </p>
13 </noscript> 13 </noscript>
14 <div id="app"></div> 14 <div id="app"></div>
15 <script src="./src/index.tsx" type="module"></script>
15 </body> 16 </body>
16</html> 17</html>
diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json
index 12aff7bf..59a042b3 100644
--- a/subprojects/frontend/package.json
+++ b/subprojects/frontend/package.json
@@ -2,16 +2,14 @@
2 "name": "@refinery/frontend", 2 "name": "@refinery/frontend",
3 "version": "0.0.0", 3 "version": "0.0.0",
4 "description": "Web frontend for Refinery", 4 "description": "Web frontend for Refinery",
5 "main": "index.js", 5 "prive": true,
6 "scripts": { 6 "scripts": {
7 "assemble:lezer": "lezer-generator src/language/problem.grammar -o build/generated/sources/lezer/problem.ts", 7 "build": "cross-env MODE=production vite build",
8 "assemble:webpack": "webpack --node-env production", 8 "serve": "cross-env MODE=development vite serve",
9 "serve": "webpack serve --node-env development --hot", 9 "typecheck": "tsc -p tsconfig.node.json && tsc -p tsconfig.json",
10 "check": "yarn run check:eslint && yarn run check:stylelint", 10 "lint": "eslint .",
11 "check:eslint": "eslint .", 11 "lint:ci": "eslint -f json -o build/eslint.json .",
12 "check:eslint:ci": "eslint -f json -o build/eslint.json .", 12 "lint:fix": "yarn run lint --fix"
13 "check:stylelint": "stylelint src/**/*.scss",
14 "check:stylelint:ci": "stylelint -f json src/**/*.scss > build/stylelint.json"
15 }, 13 },
16 "repository": { 14 "repository": {
17 "type": "git", 15 "type": "git",
@@ -23,47 +21,7 @@
23 "url": "https://github.com/graphs4value/issues" 21 "url": "https://github.com/graphs4value/issues"
24 }, 22 },
25 "homepage": "https://refinery.tools", 23 "homepage": "https://refinery.tools",
26 "devDependencies": {
27 "@babel/core": "^7.18.10",
28 "@babel/plugin-transform-runtime": "^7.18.10",
29 "@babel/preset-env": "^7.18.10",
30 "@babel/preset-react": "^7.18.6",
31 "@babel/preset-typescript": "^7.18.6",
32 "@lezer/generator": "^1.1.1",
33 "@principalstudio/html-webpack-inject-preload": "^1.2.7",
34 "@types/react": "^18.0.17",
35 "@types/react-dom": "^18.0.6",
36 "@typescript-eslint/eslint-plugin": "^5.33.0",
37 "@typescript-eslint/parser": "^5.33.0",
38 "babel-loader": "^8.2.5",
39 "css-loader": "^6.7.1",
40 "eslint": "^8.21.0",
41 "eslint-config-airbnb": "^19.0.4",
42 "eslint-config-airbnb-typescript": "^17.0.0",
43 "eslint-import-resolver-node": "^0.3.6",
44 "eslint-plugin-import": "^2.26.0",
45 "eslint-plugin-jsx-a11y": "^6.6.1",
46 "eslint-plugin-react": "^7.30.1",
47 "eslint-plugin-react-hooks": "^4.6.0",
48 "html-webpack-plugin": "^5.5.0",
49 "image-webpack-loader": "^8.1.0",
50 "mini-css-extract-plugin": "^2.6.1",
51 "postcss": "^8.4.16",
52 "postcss-scss": "^4.0.4",
53 "sass": "^1.54.4",
54 "sass-loader": "^13.0.2",
55 "style-loader": "^3.3.1",
56 "stylelint": "^14.10.0",
57 "stylelint-config-recommended-scss": "^7.0.0",
58 "stylelint-scss": "^4.3.0",
59 "typescript": "~4.7.4",
60 "webpack": "^5.74.0",
61 "webpack-cli": "^4.10.0",
62 "webpack-dev-server": "^4.10.0",
63 "webpack-subresource-integrity": "^5.1.0"
64 },
65 "dependencies": { 24 "dependencies": {
66 "@babel/runtime": "^7.18.9",
67 "@codemirror/autocomplete": "^6.1.0", 25 "@codemirror/autocomplete": "^6.1.0",
68 "@codemirror/commands": "^6.0.1", 26 "@codemirror/commands": "^6.0.1",
69 "@codemirror/language": "^6.2.1", 27 "@codemirror/language": "^6.2.1",
@@ -77,7 +35,7 @@
77 "@fontsource/roboto": "^4.5.8", 35 "@fontsource/roboto": "^4.5.8",
78 "@lezer/common": "^1.0.0", 36 "@lezer/common": "^1.0.0",
79 "@lezer/highlight": "^1.0.0", 37 "@lezer/highlight": "^1.0.0",
80 "@lezer/lr": "^1.2.1", 38 "@lezer/lr": "^1.2.2",
81 "@mui/icons-material": "5.8.4", 39 "@mui/icons-material": "5.8.4",
82 "@mui/material": "5.10.0", 40 "@mui/material": "5.10.0",
83 "ansi-styles": "^6.1.0", 41 "ansi-styles": "^6.1.0",
@@ -90,5 +48,32 @@
90 "react": "^18.2.0", 48 "react": "^18.2.0",
91 "react-dom": "^18.2.0", 49 "react-dom": "^18.2.0",
92 "zod": "^3.18.0" 50 "zod": "^3.18.0"
51 },
52 "devDependencies": {
53 "@lezer/generator": "^1.1.1",
54 "@types/eslint": "^8.4.5",
55 "@types/node": "^18.7.2",
56 "@types/prettier": "^2.7.0",
57 "@types/react": "^18.0.17",
58 "@types/react-dom": "^18.0.6",
59 "@typescript-eslint/eslint-plugin": "^5.33.0",
60 "@typescript-eslint/parser": "^5.33.0",
61 "@vitejs/plugin-react": "^2.0.1",
62 "cross-env": "^7.0.3",
63 "eslint": "^8.21.0",
64 "eslint-config-airbnb": "^19.0.4",
65 "eslint-config-airbnb-typescript": "^17.0.0",
66 "eslint-config-prettier": "^8.5.0",
67 "eslint-import-resolver-typescript": "^3.4.0",
68 "eslint-plugin-import": "^2.26.0",
69 "eslint-plugin-jsx-a11y": "^6.6.1",
70 "eslint-plugin-prettier": "^4.2.1",
71 "eslint-plugin-react": "^7.30.1",
72 "eslint-plugin-react-hooks": "^4.6.0",
73 "prettier": "^2.7.1",
74 "rollup": "^2.77.3",
75 "typescript": "~4.7.4",
76 "vite": "^3.0.6",
77 "vite-plugin-inject-preload": "^1.0.1"
93 } 78 }
94} 79}
diff --git a/subprojects/frontend/prettier.config.cjs b/subprojects/frontend/prettier.config.cjs
new file mode 100644
index 00000000..75f5c54d
--- /dev/null
+++ b/subprojects/frontend/prettier.config.cjs
@@ -0,0 +1,5 @@
1/** @type {import('prettier').Config} */
2module.exports = {
3 singleQuote: true,
4 trailingComma: 'all',
5};
diff --git a/subprojects/frontend/src/App.tsx b/subprojects/frontend/src/App.tsx
index 54f92f9a..d3ec63eb 100644
--- a/subprojects/frontend/src/App.tsx
+++ b/subprojects/frontend/src/App.tsx
@@ -1,26 +1,19 @@
1import MenuIcon from '@mui/icons-material/Menu';
1import AppBar from '@mui/material/AppBar'; 2import AppBar from '@mui/material/AppBar';
2import Box from '@mui/material/Box'; 3import Box from '@mui/material/Box';
3import IconButton from '@mui/material/IconButton'; 4import IconButton from '@mui/material/IconButton';
4import Toolbar from '@mui/material/Toolbar'; 5import Toolbar from '@mui/material/Toolbar';
5import Typography from '@mui/material/Typography'; 6import Typography from '@mui/material/Typography';
6import MenuIcon from '@mui/icons-material/Menu';
7import React from 'react'; 7import React from 'react';
8 8
9import { EditorArea } from './editor/EditorArea'; 9import EditorArea from './editor/EditorArea';
10import { EditorButtons } from './editor/EditorButtons'; 10import EditorButtons from './editor/EditorButtons';
11import { GenerateButton } from './editor/GenerateButton'; 11import GenerateButton from './editor/GenerateButton';
12 12
13export function App(): JSX.Element { 13export default function App(): JSX.Element {
14 return ( 14 return (
15 <Box 15 <Box display="flex" flexDirection="column" sx={{ height: '100vh' }}>
16 display="flex" 16 <AppBar position="static" color="inherit">
17 flexDirection="column"
18 sx={{ height: '100vh' }}
19 >
20 <AppBar
21 position="static"
22 color="inherit"
23 >
24 <Toolbar> 17 <Toolbar>
25 <IconButton 18 <IconButton
26 edge="start" 19 edge="start"
@@ -30,11 +23,7 @@ export function App(): JSX.Element {
30 > 23 >
31 <MenuIcon /> 24 <MenuIcon />
32 </IconButton> 25 </IconButton>
33 <Typography 26 <Typography variant="h6" component="h1" flexGrow={1}>
34 variant="h6"
35 component="h1"
36 flexGrow={1}
37 >
38 Refinery 27 Refinery
39 </Typography> 28 </Typography>
40 </Toolbar> 29 </Toolbar>
@@ -48,11 +37,7 @@ export function App(): JSX.Element {
48 <EditorButtons /> 37 <EditorButtons />
49 <GenerateButton /> 38 <GenerateButton />
50 </Box> 39 </Box>
51 <Box 40 <Box flexGrow={1} flexShrink={1} sx={{ overflow: 'auto' }}>
52 flexGrow={1}
53 flexShrink={1}
54 sx={{ overflow: 'auto' }}
55 >
56 <EditorArea /> 41 <EditorArea />
57 </Box> 42 </Box>
58 </Box> 43 </Box>
diff --git a/subprojects/frontend/src/Loading.tsx b/subprojects/frontend/src/Loading.tsx
new file mode 100644
index 00000000..a699adca
--- /dev/null
+++ b/subprojects/frontend/src/Loading.tsx
@@ -0,0 +1,19 @@
1import CircularProgress from '@mui/material/CircularProgress';
2import { styled } from '@mui/material/styles';
3import React from 'react';
4
5const LoadingRoot = styled('div')({
6 width: '100vw',
7 height: '100vh',
8 display: 'flex',
9 alignItems: 'center',
10 justifyContent: 'center',
11});
12
13export default function Loading() {
14 return (
15 <LoadingRoot>
16 <CircularProgress />
17 </LoadingRoot>
18 );
19}
diff --git a/subprojects/frontend/src/RootStore.tsx b/subprojects/frontend/src/RootStore.tsx
index baf0b61e..a7406d7b 100644
--- a/subprojects/frontend/src/RootStore.tsx
+++ b/subprojects/frontend/src/RootStore.tsx
@@ -1,9 +1,9 @@
1import React, { createContext, useContext } from 'react'; 1import React, { createContext, useContext } from 'react';
2 2
3import { EditorStore } from './editor/EditorStore'; 3import EditorStore from './editor/EditorStore';
4import { ThemeStore } from './theme/ThemeStore'; 4import ThemeStore from './theme/ThemeStore';
5 5
6export class RootStore { 6export default class RootStore {
7 editorStore; 7 editorStore;
8 8
9 themeStore; 9 themeStore;
@@ -22,11 +22,12 @@ export interface RootStoreProviderProps {
22 rootStore: RootStore; 22 rootStore: RootStore;
23} 23}
24 24
25export function RootStoreProvider({ children, rootStore }: RootStoreProviderProps): JSX.Element { 25export function RootStoreProvider({
26 children,
27 rootStore,
28}: RootStoreProviderProps): JSX.Element {
26 return ( 29 return (
27 <StoreContext.Provider value={rootStore}> 30 <StoreContext.Provider value={rootStore}>{children}</StoreContext.Provider>
28 {children}
29 </StoreContext.Provider>
30 ); 31 );
31} 32}
32 33
diff --git a/subprojects/frontend/src/editor/EditorArea.tsx b/subprojects/frontend/src/editor/EditorArea.tsx
index dba20f6e..14294371 100644
--- a/subprojects/frontend/src/editor/EditorArea.tsx
+++ b/subprojects/frontend/src/editor/EditorArea.tsx
@@ -1,17 +1,13 @@
1import { Command, EditorView } from '@codemirror/view';
2import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
3import { closeLintPanel, openLintPanel } from '@codemirror/lint'; 1import { closeLintPanel, openLintPanel } from '@codemirror/lint';
2import { closeSearchPanel, openSearchPanel } from '@codemirror/search';
3import { type Command, EditorView } from '@codemirror/view';
4import { observer } from 'mobx-react-lite'; 4import { observer } from 'mobx-react-lite';
5import React, { 5import React, { useCallback, useEffect, useRef, useState } from 'react';
6 useCallback,
7 useEffect,
8 useRef,
9 useState,
10} from 'react';
11 6
12import { EditorParent } from './EditorParent';
13import { useRootStore } from '../RootStore'; 7import { useRootStore } from '../RootStore';
14import { getLogger } from '../utils/logger'; 8import getLogger from '../utils/getLogger';
9
10import EditorParent from './EditorParent';
15 11
16const log = getLogger('editor.EditorArea'); 12const log = getLogger('editor.EditorArea');
17 13
@@ -70,10 +66,12 @@ function fixCodeMirrorAccessibility(editorView: EditorView) {
70 contentDOM.setAttribute('aria-label', 'Code editor'); 66 contentDOM.setAttribute('aria-label', 'Code editor');
71} 67}
72 68
73export const EditorArea = observer(() => { 69function EditorArea(): JSX.Element {
74 const { editorStore } = useRootStore(); 70 const { editorStore } = useRootStore();
75 const editorParentRef = useRef<HTMLDivElement | null>(null); 71 const editorParentRef = useRef<HTMLDivElement | null>(null);
76 const [editorViewState, setEditorViewState] = useState<EditorView | null>(null); 72 const [editorViewState, setEditorViewState] = useState<EditorView | null>(
73 null,
74 );
77 75
78 const setSearchPanelOpen = usePanel( 76 const setSearchPanelOpen = usePanel(
79 'search', 77 'search',
@@ -131,22 +129,21 @@ export const EditorArea = observer(() => {
131 editorView.destroy(); 129 editorView.destroy();
132 log.info('Editor destroyed'); 130 log.info('Editor destroyed');
133 }; 131 };
134 }, [ 132 }, [editorStore, setSearchPanelOpen, setLintPanelOpen]);
135 editorParentRef,
136 editorStore,
137 setSearchPanelOpen,
138 setLintPanelOpen,
139 ]);
140 133
141 return ( 134 return (
142 <EditorParent 135 <EditorParent
143 className="dark" 136 className="dark"
144 sx={{ 137 sx={{
145 '.cm-lineNumbers': editorStore.showLineNumbers ? {} : { 138 '.cm-lineNumbers': editorStore.showLineNumbers
146 display: 'none !important', 139 ? {}
147 }, 140 : {
141 display: 'none !important',
142 },
148 }} 143 }}
149 ref={editorParentRef} 144 ref={editorParentRef}
150 /> 145 />
151 ); 146 );
152}); 147}
148
149export default observer(EditorArea);
diff --git a/subprojects/frontend/src/editor/EditorButtons.tsx b/subprojects/frontend/src/editor/EditorButtons.tsx
index 150aa00d..652ca71e 100644
--- a/subprojects/frontend/src/editor/EditorButtons.tsx
+++ b/subprojects/frontend/src/editor/EditorButtons.tsx
@@ -1,9 +1,4 @@
1import type { Diagnostic } from '@codemirror/lint'; 1import type { Diagnostic } from '@codemirror/lint';
2import { observer } from 'mobx-react-lite';
3import IconButton from '@mui/material/IconButton';
4import Stack from '@mui/material/Stack';
5import ToggleButton from '@mui/material/ToggleButton';
6import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
7import CheckIcon from '@mui/icons-material/Check'; 2import CheckIcon from '@mui/icons-material/Check';
8import ErrorIcon from '@mui/icons-material/Error'; 3import ErrorIcon from '@mui/icons-material/Error';
9import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; 4import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
@@ -13,6 +8,11 @@ import RedoIcon from '@mui/icons-material/Redo';
13import SearchIcon from '@mui/icons-material/Search'; 8import SearchIcon from '@mui/icons-material/Search';
14import UndoIcon from '@mui/icons-material/Undo'; 9import UndoIcon from '@mui/icons-material/Undo';
15import WarningIcon from '@mui/icons-material/Warning'; 10import WarningIcon from '@mui/icons-material/Warning';
11import IconButton from '@mui/material/IconButton';
12import Stack from '@mui/material/Stack';
13import ToggleButton from '@mui/material/ToggleButton';
14import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
15import { observer } from 'mobx-react-lite';
16import React from 'react'; 16import React from 'react';
17 17
18import { useRootStore } from '../RootStore'; 18import { useRootStore } from '../RootStore';
@@ -27,23 +27,17 @@ function getLintIcon(severity: Diagnostic['severity'] | null) {
27 return <WarningIcon fontSize="small" />; 27 return <WarningIcon fontSize="small" />;
28 case 'info': 28 case 'info':
29 return <InfoOutlinedIcon fontSize="small" />; 29 return <InfoOutlinedIcon fontSize="small" />;
30 case null: 30 default:
31 return <CheckIcon fontSize="small" />; 31 return <CheckIcon fontSize="small" />;
32 } 32 }
33} 33}
34 34
35export const EditorButtons = observer(() => { 35function EditorButtons(): JSX.Element {
36 const { editorStore } = useRootStore(); 36 const { editorStore } = useRootStore();
37 37
38 return ( 38 return (
39 <Stack 39 <Stack direction="row" spacing={1}>
40 direction="row" 40 <Stack direction="row" alignItems="center">
41 spacing={1}
42 >
43 <Stack
44 direction="row"
45 alignItems="center"
46 >
47 <IconButton 41 <IconButton
48 disabled={!editorStore.canUndo} 42 disabled={!editorStore.canUndo}
49 onClick={() => editorStore.undo()} 43 onClick={() => editorStore.undo()}
@@ -59,9 +53,7 @@ export const EditorButtons = observer(() => {
59 <RedoIcon fontSize="small" /> 53 <RedoIcon fontSize="small" />
60 </IconButton> 54 </IconButton>
61 </Stack> 55 </Stack>
62 <ToggleButtonGroup 56 <ToggleButtonGroup size="small">
63 size="small"
64 >
65 <ToggleButton 57 <ToggleButton
66 selected={editorStore.showLineNumbers} 58 selected={editorStore.showLineNumbers}
67 onClick={() => editorStore.toggleLineNumbers()} 59 onClick={() => editorStore.toggleLineNumbers()}
@@ -95,4 +87,6 @@ export const EditorButtons = observer(() => {
95 </IconButton> 87 </IconButton>
96 </Stack> 88 </Stack>
97 ); 89 );
98}); 90}
91
92export default observer(EditorButtons);
diff --git a/subprojects/frontend/src/editor/EditorParent.ts b/subprojects/frontend/src/editor/EditorParent.ts
index 9aaf541a..dbc35a0d 100644
--- a/subprojects/frontend/src/editor/EditorParent.ts
+++ b/subprojects/frontend/src/editor/EditorParent.ts
@@ -1,4 +1,4 @@
1import { styled } from '@mui/material/styles'; 1import { alpha, styled } from '@mui/material/styles';
2 2
3/** 3/**
4 * Returns a squiggly underline background image encoded as a CSS `url()` data URI with Base64. 4 * Returns a squiggly underline background image encoded as a CSS `url()` data URI with Base64.
@@ -17,7 +17,9 @@ function underline(color: string) {
17 return `url('data:image/svg+xml;base64,${svgBase64}')`; 17 return `url('data:image/svg+xml;base64,${svgBase64}')`;
18} 18}
19 19
20export const EditorParent = styled('div')(({ theme }) => { 20export default styled('div', {
21 name: 'EditorParent',
22})(({ theme }) => {
21 const codeMirrorLintStyle: Record<string, unknown> = {}; 23 const codeMirrorLintStyle: Record<string, unknown> = {};
22 (['error', 'warning', 'info'] as const).forEach((severity) => { 24 (['error', 'warning', 'info'] as const).forEach((severity) => {
23 const color = theme.palette[severity].main; 25 const color = theme.palette[severity].main;
@@ -37,19 +39,20 @@ export const EditorParent = styled('div')(({ theme }) => {
37 '.cm-content': { 39 '.cm-content': {
38 padding: 0, 40 padding: 0,
39 }, 41 },
40 '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail': { 42 '.cm-scroller, .cm-tooltip-autocomplete, .cm-completionLabel, .cm-completionDetail':
41 fontSize: 16, 43 {
42 fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace', 44 fontSize: 16,
43 fontFeatureSettings: '"liga", "calt"', 45 fontFamily: '"JetBrains MonoVariable", "JetBrains Mono", monospace',
44 fontWeight: 400, 46 fontFeatureSettings: '"liga", "calt"',
45 letterSpacing: 0, 47 fontWeight: 400,
46 textRendering: 'optimizeLegibility', 48 letterSpacing: 0,
47 }, 49 textRendering: 'optimizeLegibility',
50 },
48 '.cm-scroller': { 51 '.cm-scroller': {
49 color: theme.palette.text.secondary, 52 color: theme.palette.text.secondary,
50 }, 53 },
51 '.cm-gutters': { 54 '.cm-gutters': {
52 background: 'rgba(255, 255, 255, 0.1)', 55 background: 'transparent',
53 color: theme.palette.text.disabled, 56 color: theme.palette.text.disabled,
54 border: 'none', 57 border: 'none',
55 }, 58 },
@@ -57,7 +60,19 @@ export const EditorParent = styled('div')(({ theme }) => {
57 color: theme.palette.secondary.main, 60 color: theme.palette.secondary.main,
58 }, 61 },
59 '.cm-activeLine': { 62 '.cm-activeLine': {
60 background: 'rgba(0, 0, 0, 0.3)', 63 background: alpha(theme.palette.text.secondary, 0.06),
64 },
65 '.cm-foldGutter': {
66 color: alpha(theme.palette.text.primary, 0),
67 transition: theme.transitions.create('color', {
68 duration: theme.transitions.duration.short,
69 }),
70 '@media (hover: none)': {
71 color: theme.palette.text.primary,
72 },
73 },
74 '.cm-gutters:hover .cm-foldGutter': {
75 color: theme.palette.text.primary,
61 }, 76 },
62 '.cm-activeLineGutter': { 77 '.cm-activeLineGutter': {
63 background: 'transparent', 78 background: 'transparent',
@@ -66,8 +81,7 @@ export const EditorParent = styled('div')(({ theme }) => {
66 color: theme.palette.text.primary, 81 color: theme.palette.text.primary,
67 }, 82 },
68 '.cm-cursor, .cm-cursor-primary': { 83 '.cm-cursor, .cm-cursor-primary': {
69 borderColor: theme.palette.primary.main, 84 borderLeft: `2px solid ${theme.palette.primary.main}`,
70 background: theme.palette.common.black,
71 }, 85 },
72 '.cm-selectionBackground': { 86 '.cm-selectionBackground': {
73 background: '#3e4453', 87 background: '#3e4453',
@@ -115,9 +129,26 @@ export const EditorParent = styled('div')(({ theme }) => {
115 }, 129 },
116 }, 130 },
117 '.cm-foldPlaceholder': { 131 '.cm-foldPlaceholder': {
118 background: theme.palette.background.paper,
119 borderColor: theme.palette.text.disabled,
120 color: theme.palette.text.secondary, 132 color: theme.palette.text.secondary,
133 backgroundColor: alpha(theme.palette.text.secondary, 0),
134 border: `1px solid ${alpha(theme.palette.text.secondary, 0.5)}`,
135 borderRadius: theme.shape.borderRadius,
136 transition: theme.transitions.create(
137 ['background-color', 'border-color', 'color'],
138 {
139 duration: theme.transitions.duration.short,
140 },
141 ),
142 '&:hover': {
143 backgroundColor: alpha(
144 theme.palette.text.secondary,
145 theme.palette.action.hoverOpacity,
146 ),
147 borderColor: theme.palette.text.secondary,
148 '@media (hover: none)': {
149 backgroundColor: 'transparent',
150 },
151 },
121 }, 152 },
122 '.tok-comment': { 153 '.tok-comment': {
123 fontStyle: 'italic', 154 fontStyle: 'italic',
@@ -168,9 +199,14 @@ export const EditorParent = styled('div')(({ theme }) => {
168 }, 199 },
169 '.cm-tooltip-autocomplete': { 200 '.cm-tooltip-autocomplete': {
170 background: theme.palette.background.paper, 201 background: theme.palette.background.paper,
171 boxShadow: `0px 2px 4px -1px rgb(0 0 0 / 20%), 202 ...(theme.palette.mode === 'dark' && {
172 0px 4px 5px 0px rgb(0 0 0 / 14%), 203 overflow: 'hidden',
173 0px 1px 10px 0px rgb(0 0 0 / 12%)`, 204 borderRadius: theme.shape.borderRadius,
205 // https://github.com/mui/material-ui/blob/10c72729c7d03bab8cdce6eb422642684c56dca2/packages/mui-material/src/Paper/Paper.js#L18
206 backgroundImage:
207 'linear-gradient(rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.09))',
208 }),
209 boxShadow: theme.shadows[4],
174 '.cm-completionIcon': { 210 '.cm-completionIcon': {
175 color: theme.palette.text.secondary, 211 color: theme.palette.text.secondary,
176 }, 212 },
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts
index 0f4d2936..f75147a4 100644
--- a/subprojects/frontend/src/editor/EditorStore.ts
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -21,18 +21,14 @@ import {
21 indentOnInput, 21 indentOnInput,
22 syntaxHighlighting, 22 syntaxHighlighting,
23} from '@codemirror/language'; 23} from '@codemirror/language';
24import { 24import { type Diagnostic, lintKeymap, setDiagnostics } from '@codemirror/lint';
25 Diagnostic,
26 lintKeymap,
27 setDiagnostics,
28} from '@codemirror/lint';
29import { search, searchKeymap } from '@codemirror/search'; 25import { search, searchKeymap } from '@codemirror/search';
30import { 26import {
31 EditorState, 27 EditorState,
32 StateCommand, 28 type StateCommand,
33 StateEffect, 29 StateEffect,
34 Transaction, 30 type Transaction,
35 TransactionSpec, 31 type TransactionSpec,
36} from '@codemirror/state'; 32} from '@codemirror/state';
37import { 33import {
38 drawSelection, 34 drawSelection,
@@ -45,26 +41,25 @@ import {
45 rectangularSelection, 41 rectangularSelection,
46} from '@codemirror/view'; 42} from '@codemirror/view';
47import { classHighlighter } from '@lezer/highlight'; 43import { classHighlighter } from '@lezer/highlight';
48import { 44import { makeAutoObservable, observable, reaction } from 'mobx';
49 makeAutoObservable, 45
50 observable, 46import problemLanguageSupport from '../language/problemLanguageSupport';
51 reaction, 47import type ThemeStore from '../theme/ThemeStore';
52} from 'mobx'; 48import getLogger from '../utils/getLogger';
53 49import XtextClient from '../xtext/XtextClient';
54import { findOccurrences, IOccurrence, setOccurrences } from './findOccurrences'; 50
55import { problemLanguageSupport } from '../language/problemLanguageSupport'; 51import findOccurrences, {
56import { 52 type IOccurrence,
57 IHighlightRange, 53 setOccurrences,
58 semanticHighlighting, 54} from './findOccurrences';
55import semanticHighlighting, {
56 type IHighlightRange,
59 setSemanticHighlighting, 57 setSemanticHighlighting,
60} from './semanticHighlighting'; 58} from './semanticHighlighting';
61import type { ThemeStore } from '../theme/ThemeStore';
62import { getLogger } from '../utils/logger';
63import { XtextClient } from '../xtext/XtextClient';
64 59
65const log = getLogger('editor.EditorStore'); 60const log = getLogger('editor.EditorStore');
66 61
67export class EditorStore { 62export default class EditorStore {
68 private readonly themeStore; 63 private readonly themeStore;
69 64
70 state: EditorState; 65 state: EditorState;
@@ -96,17 +91,18 @@ export class EditorStore {
96 extensions: [ 91 extensions: [
97 autocompletion({ 92 autocompletion({
98 activateOnTyping: true, 93 activateOnTyping: true,
99 override: [ 94 override: [(context) => this.client.contentAssist(context)],
100 (context) => this.client.contentAssist(context),
101 ],
102 }), 95 }),
103 closeBrackets(), 96 closeBrackets(),
104 bracketMatching(), 97 bracketMatching(),
105 drawSelection(), 98 drawSelection(),
106 EditorState.allowMultipleSelections.of(true), 99 EditorState.allowMultipleSelections.of(true),
107 EditorView.theme({}, { 100 EditorView.theme(
108 dark: this.themeStore.darkMode, 101 {},
109 }), 102 {
103 dark: this.themeStore.darkMode,
104 },
105 ),
110 findOccurrences, 106 findOccurrences,
111 highlightActiveLine(), 107 highlightActiveLine(),
112 highlightActiveLineGutter(), 108 highlightActiveLineGutter(),
@@ -134,8 +130,16 @@ export class EditorStore {
134 { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) }, 130 { key: 'Mod-Shift-m', run: () => this.setLintPanelOpen(true) },
135 ...lintKeymap, 131 ...lintKeymap,
136 // Override keys in `searchKeymap` to go through the `EditorStore`. 132 // Override keys in `searchKeymap` to go through the `EditorStore`.
137 { key: 'Mod-f', run: () => this.setSearchPanelOpen(true), scope: 'editor search-panel' }, 133 {
138 { key: 'Escape', run: () => this.setSearchPanelOpen(false), scope: 'editor search-panel' }, 134 key: 'Mod-f',
135 run: () => this.setSearchPanelOpen(true),
136 scope: 'editor search-panel',
137 },
138 {
139 key: 'Escape',
140 run: () => this.setSearchPanelOpen(false),
141 scope: 'editor search-panel',
142 },
139 ...searchKeymap, 143 ...searchKeymap,
140 ...defaultKeymap, 144 ...defaultKeymap,
141 ]), 145 ]),
@@ -149,9 +153,14 @@ export class EditorStore {
149 log.debug('Update editor dark mode', darkMode); 153 log.debug('Update editor dark mode', darkMode);
150 this.dispatch({ 154 this.dispatch({
151 effects: [ 155 effects: [
152 StateEffect.appendConfig.of(EditorView.theme({}, { 156 StateEffect.appendConfig.of(
153 dark: darkMode, 157 EditorView.theme(
154 })), 158 {},
159 {
160 dark: darkMode,
161 },
162 ),
163 ),
155 ], 164 ],
156 }); 165 });
157 }, 166 },
@@ -198,6 +207,8 @@ export class EditorStore {
198 case 'info': 207 case 'info':
199 this.infoCount += 1; 208 this.infoCount += 1;
200 break; 209 break;
210 default:
211 throw new Error('Unknown severity');
201 } 212 }
202 }); 213 });
203 } 214 }
@@ -261,7 +272,7 @@ export class EditorStore {
261 * This matches the behavior of the `openSearchPanel` and `closeSearchPanel` 272 * This matches the behavior of the `openSearchPanel` and `closeSearchPanel`
262 * commands from `'@codemirror/search'`. 273 * commands from `'@codemirror/search'`.
263 * 274 *
264 * @param newShosSearchPanel whether we should show the search panel 275 * @param newShowSearchPanel whether we should show the search panel
265 * @returns `true` if the state was changed, `false` otherwise 276 * @returns `true` if the state was changed, `false` otherwise
266 */ 277 */
267 setSearchPanelOpen(newShowSearchPanel: boolean): boolean { 278 setSearchPanelOpen(newShowSearchPanel: boolean): boolean {
diff --git a/subprojects/frontend/src/editor/GenerateButton.tsx b/subprojects/frontend/src/editor/GenerateButton.tsx
index 3834cec4..fc337da9 100644
--- a/subprojects/frontend/src/editor/GenerateButton.tsx
+++ b/subprojects/frontend/src/editor/GenerateButton.tsx
@@ -1,13 +1,13 @@
1import { observer } from 'mobx-react-lite';
2import Button from '@mui/material/Button';
3import PlayArrowIcon from '@mui/icons-material/PlayArrow'; 1import PlayArrowIcon from '@mui/icons-material/PlayArrow';
2import Button from '@mui/material/Button';
3import { observer } from 'mobx-react-lite';
4import React from 'react'; 4import React from 'react';
5 5
6import { useRootStore } from '../RootStore'; 6import { useRootStore } from '../RootStore';
7 7
8const GENERATE_LABEL = 'Generate'; 8const GENERATE_LABEL = 'Generate';
9 9
10export const GenerateButton = observer(() => { 10function GenerateButton(): JSX.Element {
11 const { editorStore } = useRootStore(); 11 const { editorStore } = useRootStore();
12 const { errorCount, warningCount } = editorStore; 12 const { errorCount, warningCount } = editorStore;
13 13
@@ -41,4 +41,6 @@ export const GenerateButton = observer(() => {
41 {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`} 41 {summary === '' ? GENERATE_LABEL : `${GENERATE_LABEL} (${summary})`}
42 </Button> 42 </Button>
43 ); 43 );
44}); 44}
45
46export default observer(GenerateButton);
diff --git a/subprojects/frontend/src/editor/decorationSetExtension.ts b/subprojects/frontend/src/editor/defineDecorationSetExtension.ts
index 2d630c20..d9c7bc7d 100644
--- a/subprojects/frontend/src/editor/decorationSetExtension.ts
+++ b/subprojects/frontend/src/editor/defineDecorationSetExtension.ts
@@ -1,11 +1,16 @@
1import { StateEffect, StateField, TransactionSpec } from '@codemirror/state'; 1import { StateEffect, StateField, TransactionSpec } from '@codemirror/state';
2import { EditorView, Decoration, DecorationSet } from '@codemirror/view'; 2import { EditorView, Decoration, DecorationSet } from '@codemirror/view';
3 3
4export type TransactionSpecFactory = (decorations: DecorationSet) => TransactionSpec; 4export type TransactionSpecFactory = (
5 decorations: DecorationSet,
6) => TransactionSpec;
5 7
6export function decorationSetExtension(): [TransactionSpecFactory, StateField<DecorationSet>] { 8export default function defineDecorationSetExtension(): [
9 TransactionSpecFactory,
10 StateField<DecorationSet>,
11] {
7 const setEffect = StateEffect.define<DecorationSet>(); 12 const setEffect = StateEffect.define<DecorationSet>();
8 const field = StateField.define<DecorationSet>({ 13 const stateField = StateField.define<DecorationSet>({
9 create() { 14 create() {
10 return Decoration.none; 15 return Decoration.none;
11 }, 16 },
@@ -24,16 +29,14 @@ export function decorationSetExtension(): [TransactionSpecFactory, StateField<De
24 } 29 }
25 return newDecorations; 30 return newDecorations;
26 }, 31 },
27 provide: (f) => EditorView.decorations.from(f), 32 provide: (field) => EditorView.decorations.from(field),
28 }); 33 });
29 34
30 function transactionSpecFactory(decorations: DecorationSet) { 35 function transactionSpecFactory(decorations: DecorationSet) {
31 return { 36 return {
32 effects: [ 37 effects: [setEffect.of(decorations)],
33 setEffect.of(decorations),
34 ],
35 }; 38 };
36 } 39 }
37 40
38 return [transactionSpecFactory, field]; 41 return [transactionSpecFactory, stateField];
39} 42}
diff --git a/subprojects/frontend/src/editor/findOccurrences.ts b/subprojects/frontend/src/editor/findOccurrences.ts
index c4a4e8ec..d7aae8d1 100644
--- a/subprojects/frontend/src/editor/findOccurrences.ts
+++ b/subprojects/frontend/src/editor/findOccurrences.ts
@@ -1,7 +1,7 @@
1import { Range, RangeSet, type TransactionSpec } from '@codemirror/state'; 1import { type Range, RangeSet, type TransactionSpec } from '@codemirror/state';
2import { Decoration } from '@codemirror/view'; 2import { Decoration } from '@codemirror/view';
3 3
4import { decorationSetExtension } from './decorationSetExtension'; 4import defineDecorationSetExtension from './defineDecorationSetExtension';
5 5
6export interface IOccurrence { 6export interface IOccurrence {
7 from: number; 7 from: number;
@@ -9,7 +9,7 @@ export interface IOccurrence {
9 to: number; 9 to: number;
10} 10}
11 11
12const [setOccurrencesInteral, findOccurrences] = decorationSetExtension(); 12const [setOccurrencesInteral, findOccurrences] = defineDecorationSetExtension();
13 13
14const writeDecoration = Decoration.mark({ 14const writeDecoration = Decoration.mark({
15 class: 'cm-problem-write', 15 class: 'cm-problem-write',
@@ -19,7 +19,10 @@ const readDecoration = Decoration.mark({
19 class: 'cm-problem-read', 19 class: 'cm-problem-read',
20}); 20});
21 21
22export function setOccurrences(write: IOccurrence[], read: IOccurrence[]): TransactionSpec { 22export function setOccurrences(
23 write: IOccurrence[],
24 read: IOccurrence[],
25): TransactionSpec {
23 const decorations: Range<Decoration>[] = []; 26 const decorations: Range<Decoration>[] = [];
24 write.forEach(({ from, to }) => { 27 write.forEach(({ from, to }) => {
25 decorations.push(writeDecoration.range(from, to)); 28 decorations.push(writeDecoration.range(from, to));
@@ -31,4 +34,4 @@ export function setOccurrences(write: IOccurrence[], read: IOccurrence[]): Trans
31 return setOccurrencesInteral(rangeSet); 34 return setOccurrencesInteral(rangeSet);
32} 35}
33 36
34export { findOccurrences }; 37export default findOccurrences;
diff --git a/subprojects/frontend/src/editor/semanticHighlighting.ts b/subprojects/frontend/src/editor/semanticHighlighting.ts
index a5d0af7a..2c1bd67d 100644
--- a/subprojects/frontend/src/editor/semanticHighlighting.ts
+++ b/subprojects/frontend/src/editor/semanticHighlighting.ts
@@ -1,7 +1,7 @@
1import { RangeSet, type TransactionSpec } from '@codemirror/state'; 1import { RangeSet, type TransactionSpec } from '@codemirror/state';
2import { Decoration } from '@codemirror/view'; 2import { Decoration } from '@codemirror/view';
3 3
4import { decorationSetExtension } from './decorationSetExtension'; 4import defineDecorationSetExtension from './defineDecorationSetExtension';
5 5
6export interface IHighlightRange { 6export interface IHighlightRange {
7 from: number; 7 from: number;
@@ -11,13 +11,21 @@ export interface IHighlightRange {
11 classes: string[]; 11 classes: string[];
12} 12}
13 13
14const [setSemanticHighlightingInternal, semanticHighlighting] = decorationSetExtension(); 14const [setSemanticHighlightingInternal, semanticHighlighting] =
15 defineDecorationSetExtension();
15 16
16export function setSemanticHighlighting(ranges: IHighlightRange[]): TransactionSpec { 17export function setSemanticHighlighting(
17 const rangeSet = RangeSet.of(ranges.map(({ from, to, classes }) => Decoration.mark({ 18 ranges: IHighlightRange[],
18 class: classes.map((c) => `tok-problem-${c}`).join(' '), 19): TransactionSpec {
19 }).range(from, to)), true); 20 const rangeSet = RangeSet.of(
21 ranges.map(({ from, to, classes }) =>
22 Decoration.mark({
23 class: classes.map((c) => `tok-problem-${c}`).join(' '),
24 }).range(from, to),
25 ),
26 true,
27 );
20 return setSemanticHighlightingInternal(rangeSet); 28 return setSemanticHighlightingInternal(rangeSet);
21} 29}
22 30
23export { semanticHighlighting }; 31export default semanticHighlighting;
diff --git a/subprojects/frontend/src/global.d.ts b/subprojects/frontend/src/global.d.ts
deleted file mode 100644
index 0533a46e..00000000
--- a/subprojects/frontend/src/global.d.ts
+++ /dev/null
@@ -1,11 +0,0 @@
1declare const DEBUG: boolean;
2
3declare const PACKAGE_NAME: string;
4
5declare const PACKAGE_VERSION: string;
6
7declare module '*.module.scss' {
8 const cssVariables: { [key in string]?: string };
9 // eslint-disable-next-line import/no-default-export
10 export default cssVariables;
11}
diff --git a/subprojects/frontend/src/index.scss b/subprojects/frontend/src/index.scss
deleted file mode 100644
index ad876aaf..00000000
--- a/subprojects/frontend/src/index.scss
+++ /dev/null
@@ -1,16 +0,0 @@
1@use '@fontsource/roboto/scss/mixins' as Roboto;
2@use '@fontsource/jetbrains-mono/scss/mixins' as JetbrainsMono;
3
4$fontWeights: 300, 400, 500, 700;
5@each $weight in $fontWeights {
6 @include Roboto.fontFace($fontName: 'Roboto', $weight: $weight);
7 @include Roboto.fontFace($fontName: 'Roboto', $weight: $weight, $style: italic);
8}
9
10$monoFontWeights: 400, 700;
11@each $weight in $monoFontWeights {
12 @include JetbrainsMono.fontFace($fontName: 'JetBrains Mono', $weight: $weight);
13 @include JetbrainsMono.fontFace($fontName: 'JetBrains Mono', $weight: $weight, $style: italic);
14}
15@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable');
16@include JetbrainsMono.fontFaceVariable($fontName: 'JetBrains MonoVariable', $style: italic);
diff --git a/subprojects/frontend/src/index.tsx b/subprojects/frontend/src/index.tsx
index 152c0bf7..2176b277 100644
--- a/subprojects/frontend/src/index.tsx
+++ b/subprojects/frontend/src/index.tsx
@@ -1,13 +1,25 @@
1import React from 'react';
2import { createRoot } from 'react-dom/client';
3import CssBaseline from '@mui/material/CssBaseline'; 1import CssBaseline from '@mui/material/CssBaseline';
2import React, { Suspense, lazy } from 'react';
3import { createRoot } from 'react-dom/client';
4import '@fontsource/jetbrains-mono/400.css';
5import '@fontsource/jetbrains-mono/400-italic.css';
6import '@fontsource/jetbrains-mono/700.css';
7import '@fontsource/jetbrains-mono/700-italic.css';
8import '@fontsource/jetbrains-mono/variable.css';
9import '@fontsource/jetbrains-mono/variable-italic.css';
10import '@fontsource/roboto/300.css';
11import '@fontsource/roboto/300-italic.css';
12import '@fontsource/roboto/400.css';
13import '@fontsource/roboto/400-italic.css';
14import '@fontsource/roboto/500.css';
15import '@fontsource/roboto/500-italic.css';
16import '@fontsource/roboto/700.css';
17import '@fontsource/roboto/700-italic.css';
4 18
5import { App } from './App'; 19import Loading from './Loading';
6import { RootStore, RootStoreProvider } from './RootStore'; 20import RootStore, { RootStoreProvider } from './RootStore';
7import { ThemeProvider } from './theme/ThemeProvider'; 21import ThemeProvider from './theme/ThemeProvider';
8import { getLogger } from './utils/logger'; 22import getLogger from './utils/getLogger';
9
10import './index.scss';
11 23
12const log = getLogger('index'); 24const log = getLogger('index');
13 25
@@ -60,13 +72,19 @@ scope Family = 1, Person += 5..10.
60 72
61const rootStore = new RootStore(initialValue); 73const rootStore = new RootStore(initialValue);
62 74
75const App = lazy(() => import('./App.js'));
76
63const app = ( 77const app = (
64 <RootStoreProvider rootStore={rootStore}> 78 <React.StrictMode>
65 <ThemeProvider> 79 <RootStoreProvider rootStore={rootStore}>
66 <CssBaseline /> 80 <ThemeProvider>
67 <App /> 81 <CssBaseline enableColorScheme />
68 </ThemeProvider> 82 <Suspense fallback={<Loading />}>
69 </RootStoreProvider> 83 <App />
84 </Suspense>
85 </ThemeProvider>
86 </RootStoreProvider>
87 </React.StrictMode>
70); 88);
71 89
72const rootElement = document.getElementById('app'); 90const rootElement = document.getElementById('app');
diff --git a/subprojects/frontend/src/language/folding.ts b/subprojects/frontend/src/language/folding.ts
index 2560c183..9d1c04a3 100644
--- a/subprojects/frontend/src/language/folding.ts
+++ b/subprojects/frontend/src/language/folding.ts
@@ -1,7 +1,7 @@
1import { EditorState } from '@codemirror/state'; 1import type { EditorState } from '@codemirror/state';
2import type { SyntaxNode } from '@lezer/common'; 2import type { SyntaxNode } from '@lezer/common';
3 3
4export type FoldRange = { from: number, to: number }; 4export type FoldRange = { from: number; to: number };
5 5
6/** 6/**
7 * Folds a block comment between its delimiters. 7 * Folds a block comment between its delimiters.
@@ -47,7 +47,10 @@ export function foldBlockComment(node: SyntaxNode): FoldRange {
47 * @param state the editor state 47 * @param state the editor state
48 * @returns the folding range or `null` is there is nothing to fold 48 * @returns the folding range or `null` is there is nothing to fold
49 */ 49 */
50export function foldDeclaration(node: SyntaxNode, state: EditorState): FoldRange | null { 50export function foldDeclaration(
51 node: SyntaxNode,
52 state: EditorState,
53): FoldRange | null {
51 const { firstChild: open, lastChild: close } = node; 54 const { firstChild: open, lastChild: close } = node;
52 if (open === null || close === null) { 55 if (open === null || close === null) {
53 return null; 56 return null;
diff --git a/subprojects/frontend/src/language/indentation.ts b/subprojects/frontend/src/language/indentation.ts
index 1c38637f..0bd2423c 100644
--- a/subprojects/frontend/src/language/indentation.ts
+++ b/subprojects/frontend/src/language/indentation.ts
@@ -1,4 +1,4 @@
1import { TreeIndentContext } from '@codemirror/language'; 1import type { TreeIndentContext } from '@codemirror/language';
2 2
3/** 3/**
4 * Finds the `from` of first non-skipped token, if any, 4 * Finds the `from` of first non-skipped token, if any,
@@ -11,18 +11,16 @@ import { TreeIndentContext } from '@codemirror/language';
11 * @returns the alignment or `null` if there is no token after the opening keyword 11 * @returns the alignment or `null` if there is no token after the opening keyword
12 */ 12 */
13function findAlignmentAfterOpening(context: TreeIndentContext): number | null { 13function findAlignmentAfterOpening(context: TreeIndentContext): number | null {
14 const { 14 const { node: tree, simulatedBreak } = context;
15 node: tree,
16 simulatedBreak,
17 } = context;
18 const openingToken = tree.childAfter(tree.from); 15 const openingToken = tree.childAfter(tree.from);
19 if (openingToken === null) { 16 if (openingToken === null) {
20 return null; 17 return null;
21 } 18 }
22 const openingLine = context.state.doc.lineAt(openingToken.from); 19 const openingLine = context.state.doc.lineAt(openingToken.from);
23 const lineEnd = simulatedBreak == null || simulatedBreak <= openingLine.from 20 const lineEnd =
24 ? openingLine.to 21 simulatedBreak == null || simulatedBreak <= openingLine.from
25 : Math.min(openingLine.to, simulatedBreak); 22 ? openingLine.to
23 : Math.min(openingLine.to, simulatedBreak);
26 const cursor = openingToken.cursor(); 24 const cursor = openingToken.cursor();
27 while (cursor.next() && cursor.from < lineEnd) { 25 while (cursor.next() && cursor.from < lineEnd) {
28 if (!cursor.type.isSkipped) { 26 if (!cursor.type.isSkipped) {
@@ -58,7 +56,10 @@ function findAlignmentAfterOpening(context: TreeIndentContext): number | null {
58 * @param units the number of units to indent 56 * @param units the number of units to indent
59 * @returns the desired indentation level 57 * @returns the desired indentation level
60 */ 58 */
61function indentDeclarationStrategy(context: TreeIndentContext, units: number): number { 59function indentDeclarationStrategy(
60 context: TreeIndentContext,
61 units: number,
62): number {
62 const alignment = findAlignmentAfterOpening(context); 63 const alignment = findAlignmentAfterOpening(context);
63 if (alignment !== null) { 64 if (alignment !== null) {
64 return context.column(alignment); 65 return context.column(alignment);
diff --git a/subprojects/frontend/src/language/problem.grammar b/subprojects/frontend/src/language/problem.grammar
index ac0b0ea3..313df05d 100644
--- a/subprojects/frontend/src/language/problem.grammar
+++ b/subprojects/frontend/src/language/problem.grammar
@@ -1,6 +1,6 @@
1@detectDelim 1@detectDelim
2 2
3@external prop implicitCompletion from '../../../../src/language/props.ts' 3@external prop implicitCompletion from './props'
4 4
5@top Problem { statement* } 5@top Problem { statement* }
6 6
diff --git a/subprojects/frontend/src/language/problemLanguageSupport.ts b/subprojects/frontend/src/language/problemLanguageSupport.ts
index 65fb50dc..246135d8 100644
--- a/subprojects/frontend/src/language/problemLanguageSupport.ts
+++ b/subprojects/frontend/src/language/problemLanguageSupport.ts
@@ -7,9 +7,7 @@ import {
7 LRLanguage, 7 LRLanguage,
8} from '@codemirror/language'; 8} from '@codemirror/language';
9import { styleTags, tags as t } from '@lezer/highlight'; 9import { styleTags, tags as t } from '@lezer/highlight';
10import { LRParser } from '@lezer/lr';
11 10
12import { parser } from '../../build/generated/sources/lezer/problem';
13import { 11import {
14 foldBlockComment, 12 foldBlockComment,
15 foldConjunction, 13 foldConjunction,
@@ -21,8 +19,9 @@ import {
21 indentDeclaration, 19 indentDeclaration,
22 indentPredicateOrRule, 20 indentPredicateOrRule,
23} from './indentation'; 21} from './indentation';
22import { parser } from './problem.grammar';
24 23
25const parserWithMetadata = (parser as LRParser).configure({ 24const parserWithMetadata = parser.configure({
26 props: [ 25 props: [
27 styleTags({ 26 styleTags({
28 LineComment: t.lineComment, 27 LineComment: t.lineComment,
@@ -86,8 +85,6 @@ const problemLanguage = LRLanguage.define({
86 }, 85 },
87}); 86});
88 87
89export function problemLanguageSupport(): LanguageSupport { 88export default function problemLanguageSupport(): LanguageSupport {
90 return new LanguageSupport(problemLanguage, [ 89 return new LanguageSupport(problemLanguage, [indentUnit.of(' ')]);
91 indentUnit.of(' '),
92 ]);
93} 90}
diff --git a/subprojects/frontend/src/language/props.ts b/subprojects/frontend/src/language/props.ts
index 8e488bf5..65392e75 100644
--- a/subprojects/frontend/src/language/props.ts
+++ b/subprojects/frontend/src/language/props.ts
@@ -1,3 +1,5 @@
1/* eslint-disable import/prefer-default-export -- Lezer needs non-default exports */
2
1import { NodeProp } from '@lezer/common'; 3import { NodeProp } from '@lezer/common';
2 4
3export const implicitCompletion = new NodeProp({ 5export const implicitCompletion = new NodeProp({
diff --git a/subprojects/frontend/src/theme/EditorTheme.ts b/subprojects/frontend/src/theme/EditorTheme.ts
index 294192fa..a16b4c3b 100644
--- a/subprojects/frontend/src/theme/EditorTheme.ts
+++ b/subprojects/frontend/src/theme/EditorTheme.ts
@@ -1,47 +1,7 @@
1import type { PaletteMode } from '@mui/material'; 1enum EditorTheme {
2
3import cssVariables from '../themeVariables.module.scss';
4
5export enum EditorTheme {
6 Light, 2 Light,
7 Dark, 3 Dark,
4 Default = EditorTheme.Dark,
8} 5}
9 6
10export class EditorThemeData { 7export default EditorTheme;
11 className: string;
12
13 paletteMode: PaletteMode;
14
15 toggleDarkMode: EditorTheme;
16
17 foreground!: string;
18
19 foregroundHighlight!: string;
20
21 background!: string;
22
23 primary!: string;
24
25 secondary!: string;
26
27 constructor(className: string, paletteMode: PaletteMode, toggleDarkMode: EditorTheme) {
28 this.className = className;
29 this.paletteMode = paletteMode;
30 this.toggleDarkMode = toggleDarkMode;
31 Reflect.ownKeys(this).forEach((key) => {
32 if (!Reflect.get(this, key)) {
33 const cssKey = `${this.className}--${key.toString()}`;
34 if (cssKey in cssVariables) {
35 Reflect.set(this, key, cssVariables[cssKey]);
36 }
37 }
38 });
39 }
40}
41
42export const DEFAULT_THEME = EditorTheme.Dark;
43
44export const EDITOR_THEMES: { [key in EditorTheme]: EditorThemeData } = {
45 [EditorTheme.Light]: new EditorThemeData('light', 'light', EditorTheme.Dark),
46 [EditorTheme.Dark]: new EditorThemeData('dark', 'dark', EditorTheme.Light),
47};
diff --git a/subprojects/frontend/src/theme/ThemeProvider.tsx b/subprojects/frontend/src/theme/ThemeProvider.tsx
index c6194c69..cf18e21c 100644
--- a/subprojects/frontend/src/theme/ThemeProvider.tsx
+++ b/subprojects/frontend/src/theme/ThemeProvider.tsx
@@ -1,15 +1,62 @@
1import {
2 createTheme,
3 responsiveFontSizes,
4 type ThemeOptions,
5 ThemeProvider as MaterialUiThemeProvider,
6} from '@mui/material/styles';
1import { observer } from 'mobx-react-lite'; 7import { observer } from 'mobx-react-lite';
2import { ThemeProvider as MaterialUiThemeProvider } from '@mui/material/styles';
3import React, { type ReactNode } from 'react'; 8import React, { type ReactNode } from 'react';
4 9
5import { useRootStore } from '../RootStore'; 10import { useRootStore } from '../RootStore';
6 11
7export const ThemeProvider: React.FC<{ children: ReactNode }> = observer(({ children }) => { 12import EditorTheme from './EditorTheme';
8 const { themeStore } = useRootStore(); 13
14function getMUIThemeOptions(currentTheme: EditorTheme): ThemeOptions {
15 switch (currentTheme) {
16 case EditorTheme.Light:
17 return {
18 palette: {
19 primary: {
20 main: '#56b6c2',
21 },
22 },
23 };
24 case EditorTheme.Dark:
25 return {
26 palette: {
27 primary: {
28 main: '#56b6c2',
29 },
30 },
31 };
32 default:
33 throw new Error(`Unknown theme: ${currentTheme}`);
34 }
35}
36
37function ThemeProvider({ children }: { children?: ReactNode }) {
38 const {
39 themeStore: { currentTheme, darkMode },
40 } = useRootStore();
41
42 const themeOptions = getMUIThemeOptions(currentTheme);
43 const theme = responsiveFontSizes(
44 createTheme({
45 ...themeOptions,
46 palette: {
47 mode: darkMode ? 'dark' : 'light',
48 ...(themeOptions.palette ?? {}),
49 },
50 }),
51 );
9 52
10 return ( 53 return (
11 <MaterialUiThemeProvider theme={themeStore.materialUiTheme}> 54 <MaterialUiThemeProvider theme={theme}>{children}</MaterialUiThemeProvider>
12 {children}
13 </MaterialUiThemeProvider>
14 ); 55 );
15}); 56}
57
58ThemeProvider.defaultProps = {
59 children: undefined,
60};
61
62export default observer(ThemeProvider);
diff --git a/subprojects/frontend/src/theme/ThemeStore.ts b/subprojects/frontend/src/theme/ThemeStore.ts
index ffaf6dde..ded1f29a 100644
--- a/subprojects/frontend/src/theme/ThemeStore.ts
+++ b/subprojects/frontend/src/theme/ThemeStore.ts
@@ -1,64 +1,28 @@
1import { makeAutoObservable } from 'mobx'; 1import { makeAutoObservable } from 'mobx';
2import {
3 Theme,
4 createTheme,
5 responsiveFontSizes,
6} from '@mui/material/styles';
7 2
8import { 3import EditorTheme from './EditorTheme';
9 EditorTheme,
10 EditorThemeData,
11 DEFAULT_THEME,
12 EDITOR_THEMES,
13} from './EditorTheme';
14 4
15export class ThemeStore { 5export default class ThemeStore {
16 currentTheme: EditorTheme = DEFAULT_THEME; 6 currentTheme: EditorTheme = EditorTheme.Default;
17 7
18 constructor() { 8 constructor() {
19 makeAutoObservable(this); 9 makeAutoObservable(this);
20 } 10 }
21 11
22 toggleDarkMode(): void { 12 toggleDarkMode(): void {
23 this.currentTheme = this.currentThemeData.toggleDarkMode; 13 switch (this.currentTheme) {
24 } 14 case EditorTheme.Light:
25 15 this.currentTheme = EditorTheme.Dark;
26 private get currentThemeData(): EditorThemeData { 16 break;
27 return EDITOR_THEMES[this.currentTheme]; 17 case EditorTheme.Dark:
28 } 18 this.currentTheme = EditorTheme.Light;
29 19 break;
30 get materialUiTheme(): Theme { 20 default:
31 const themeData = this.currentThemeData; 21 throw new Error(`Unknown theme: ${this.currentTheme}`);
32 const materialUiTheme = createTheme({ 22 }
33 palette: {
34 mode: themeData.paletteMode,
35 background: {
36 default: themeData.background,
37 paper: themeData.background,
38 },
39 primary: {
40 main: themeData.primary,
41 },
42 secondary: {
43 main: themeData.secondary,
44 },
45 error: {
46 main: themeData.secondary,
47 },
48 text: {
49 primary: themeData.foregroundHighlight,
50 secondary: themeData.foreground,
51 },
52 },
53 });
54 return responsiveFontSizes(materialUiTheme);
55 } 23 }
56 24
57 get darkMode(): boolean { 25 get darkMode(): boolean {
58 return this.currentThemeData.paletteMode === 'dark'; 26 return this.currentTheme === EditorTheme.Dark;
59 }
60
61 get className(): string {
62 return this.currentThemeData.className;
63 } 27 }
64} 28}
diff --git a/subprojects/frontend/src/themeVariables.module.scss b/subprojects/frontend/src/themeVariables.module.scss
deleted file mode 100644
index 85af4219..00000000
--- a/subprojects/frontend/src/themeVariables.module.scss
+++ /dev/null
@@ -1,9 +0,0 @@
1@import './themes';
2
3:export {
4 @each $themeName, $theme in $themes {
5 @each $variable, $value in $theme {
6 #{$themeName}--#{$variable}: $value,
7 }
8 }
9}
diff --git a/subprojects/frontend/src/themes.scss b/subprojects/frontend/src/themes.scss
deleted file mode 100644
index a30f1de3..00000000
--- a/subprojects/frontend/src/themes.scss
+++ /dev/null
@@ -1,38 +0,0 @@
1$themes: (
2 'dark': (
3 'foreground': #abb2bf,
4 'foregroundHighlight': #eeffff,
5 'background': #212121,
6 'primary': #56b6c2,
7 'secondary': #ff5370,
8 'keyword': #56b6c2,
9 'predicate': #d6e9ff,
10 'variable': #c8ae9d,
11 'uniqueNode': #d6e9ff,
12 'number': #6e88a6,
13 'delimiter': #707787,
14 'comment': #5c6370,
15 'cursor': #56b6c2,
16 'selection': #3e4452,
17 'currentLine': rgba(0, 0, 0, 0.2),
18 'lineNumber': #5c6370,
19 ),
20 'light': (
21 'foreground': #abb2bf,
22 'background': #282c34,
23 'paper': #21252b,
24 'primary': #56b6c2,
25 'secondary': #ff5370,
26 'keyword': #56b6c2,
27 'predicate': #d6e9ff,
28 'variable': #c8ae9d,
29 'uniqueNode': #d6e9ff,
30 'number': #6e88a6,
31 'delimiter': #56606d,
32 'comment': #55606d,
33 'cursor': #f3efe7,
34 'selection': #3e4452,
35 'currentLine': #2c323c,
36 'lineNumber': #5c6370,
37 ),
38);
diff --git a/subprojects/frontend/src/utils/ConditionVariable.ts b/subprojects/frontend/src/utils/ConditionVariable.ts
index 0910dfa6..c8fae9e8 100644
--- a/subprojects/frontend/src/utils/ConditionVariable.ts
+++ b/subprojects/frontend/src/utils/ConditionVariable.ts
@@ -1,11 +1,11 @@
1import { getLogger } from './logger'; 1import PendingTask from './PendingTask';
2import { PendingTask } from './PendingTask'; 2import getLogger from './getLogger';
3 3
4const log = getLogger('utils.ConditionVariable'); 4const log = getLogger('utils.ConditionVariable');
5 5
6export type Condition = () => boolean; 6export type Condition = () => boolean;
7 7
8export class ConditionVariable { 8export default class ConditionVariable {
9 condition: Condition; 9 condition: Condition;
10 10
11 defaultTimeout: number; 11 defaultTimeout: number;
diff --git a/subprojects/frontend/src/utils/PendingTask.ts b/subprojects/frontend/src/utils/PendingTask.ts
index 51b79fb0..086993d4 100644
--- a/subprojects/frontend/src/utils/PendingTask.ts
+++ b/subprojects/frontend/src/utils/PendingTask.ts
@@ -1,8 +1,8 @@
1import { getLogger } from './logger'; 1import getLogger from './getLogger';
2 2
3const log = getLogger('utils.PendingTask'); 3const log = getLogger('utils.PendingTask');
4 4
5export class PendingTask<T> { 5export default class PendingTask<T> {
6 private readonly resolveCallback: (value: T) => void; 6 private readonly resolveCallback: (value: T) => void;
7 7
8 private readonly rejectCallback: (reason?: unknown) => void; 8 private readonly rejectCallback: (reason?: unknown) => void;
diff --git a/subprojects/frontend/src/utils/Timer.ts b/subprojects/frontend/src/utils/Timer.ts
index 8f653070..14e9eb81 100644
--- a/subprojects/frontend/src/utils/Timer.ts
+++ b/subprojects/frontend/src/utils/Timer.ts
@@ -1,4 +1,4 @@
1export class Timer { 1export default class Timer {
2 readonly callback: () => void; 2 readonly callback: () => void;
3 3
4 readonly defaultTimeout: number; 4 readonly defaultTimeout: number;
diff --git a/subprojects/frontend/src/utils/logger.ts b/subprojects/frontend/src/utils/getLogger.ts
index 306d122c..301fd76d 100644
--- a/subprojects/frontend/src/utils/logger.ts
+++ b/subprojects/frontend/src/utils/getLogger.ts
@@ -1,6 +1,6 @@
1import styles, { CSPair } from 'ansi-styles'; 1import styles, { type CSPair } from 'ansi-styles';
2import log from 'loglevel'; 2import log from 'loglevel';
3import * as prefix from 'loglevel-plugin-prefix'; 3import prefix from 'loglevel-plugin-prefix';
4 4
5const colors: Partial<Record<string, CSPair>> = { 5const colors: Partial<Record<string, CSPair>> = {
6 TRACE: styles.magenta, 6 TRACE: styles.magenta,
@@ -12,7 +12,7 @@ const colors: Partial<Record<string, CSPair>> = {
12 12
13prefix.reg(log); 13prefix.reg(log);
14 14
15if (DEBUG) { 15if (import.meta.env.DEV) {
16 log.setLevel(log.levels.DEBUG); 16 log.setLevel(log.levels.DEBUG);
17} else { 17} else {
18 log.setLevel(log.levels.WARN); 18 log.setLevel(log.levels.WARN);
@@ -22,10 +22,14 @@ if ('chrome' in window) {
22 // Only Chromium supports console ANSI escape sequences. 22 // Only Chromium supports console ANSI escape sequences.
23 prefix.apply(log, { 23 prefix.apply(log, {
24 format(level, name, timestamp) { 24 format(level, name, timestamp) {
25 const formattedTimestamp = `${styles.gray.open}[${timestamp.toString()}]${styles.gray.close}`; 25 const formattedTimestamp = `${styles.gray.open}[${timestamp.toString()}]${
26 styles.gray.close
27 }`;
26 const levelColor = colors[level.toUpperCase()] || styles.red; 28 const levelColor = colors[level.toUpperCase()] || styles.red;
27 const formattedLevel = `${levelColor.open}${level}${levelColor.close}`; 29 const formattedLevel = `${levelColor.open}${level}${levelColor.close}`;
28 const formattedName = `${styles.green.open}(${name || 'root'})${styles.green.close}`; 30 const formattedName = `${styles.green.open}(${name || 'root'})${
31 styles.green.close
32 }`;
29 return `${formattedTimestamp} ${formattedLevel} ${formattedName}`; 33 return `${formattedTimestamp} ${formattedLevel} ${formattedName}`;
30 }, 34 },
31 }); 35 });
@@ -35,15 +39,17 @@ if ('chrome' in window) {
35 }); 39 });
36} 40}
37 41
38const appLogger = log.getLogger(PACKAGE_NAME); 42const appLogger = log.getLogger(import.meta.env.VITE_PACKAGE_NAME);
39 43
40appLogger.info('Version:', PACKAGE_NAME, PACKAGE_VERSION); 44appLogger.info(
41appLogger.info('Debug mode:', DEBUG); 45 'Version:',
46 import.meta.env.VITE_PACKAGE_NAME,
47 import.meta.env.VITE_PACKAGE_VERSION,
48);
49appLogger.info('Debug mode:', import.meta.env.DEV);
42 50
43export function getLoggerFromRoot(name: string | symbol): log.Logger { 51export default function getLogger(name: string | symbol): log.Logger {
44 return log.getLogger(name); 52 return log.getLogger(
45} 53 `${import.meta.env.VITE_PACKAGE_NAME}.${name.toString()}`,
46 54 );
47export function getLogger(name: string | symbol): log.Logger {
48 return getLoggerFromRoot(`${PACKAGE_NAME}.${name.toString()}`);
49} 55}
diff --git a/subprojects/frontend/src/xtext/ContentAssistService.ts b/subprojects/frontend/src/xtext/ContentAssistService.ts
index bedd3b5c..dce2a902 100644
--- a/subprojects/frontend/src/xtext/ContentAssistService.ts
+++ b/subprojects/frontend/src/xtext/ContentAssistService.ts
@@ -8,8 +8,9 @@ import type { Transaction } from '@codemirror/state';
8import escapeStringRegexp from 'escape-string-regexp'; 8import escapeStringRegexp from 'escape-string-regexp';
9 9
10import { implicitCompletion } from '../language/props'; 10import { implicitCompletion } from '../language/props';
11import type { UpdateService } from './UpdateService'; 11import getLogger from '../utils/getLogger';
12import { getLogger } from '../utils/logger'; 12
13import type UpdateService from './UpdateService';
13import type { ContentAssistEntry } from './xtextServiceResults'; 14import type { ContentAssistEntry } from './xtextServiceResults';
14 15
15const PROPOSALS_LIMIT = 1000; 16const PROPOSALS_LIMIT = 1000;
@@ -48,10 +49,13 @@ function findToken({ pos, state }: CompletionContext): IFoundToken | null {
48 }; 49 };
49} 50}
50 51
51function shouldCompleteImplicitly(token: IFoundToken | null, context: CompletionContext): boolean { 52function shouldCompleteImplicitly(
52 return token !== null 53 token: IFoundToken | null,
53 && token.implicitCompletion 54 context: CompletionContext,
54 && context.pos - token.from >= 2; 55): boolean {
56 return (
57 token !== null && token.implicitCompletion && context.pos - token.from >= 2
58 );
55} 59}
56 60
57function computeSpan(prefix: string, entryCount: number): RegExp { 61function computeSpan(prefix: string, entryCount: number): RegExp {
@@ -78,23 +82,29 @@ function createCompletion(entry: ContentAssistEntry): Completion {
78 case 'SNIPPET': 82 case 'SNIPPET':
79 boost = -90; 83 boost = -90;
80 break; 84 break;
81 default: { 85 default:
82 // Penalize qualified names (vs available unqualified names). 86 {
83 const extraSegments = entry.proposal.match(/::/g)?.length || 0; 87 // Penalize qualified names (vs available unqualified names).
84 boost = Math.max(-5 * extraSegments, -50); 88 const extraSegments = entry.proposal.match(/::/g)?.length || 0;
85 } 89 boost = Math.max(-5 * extraSegments, -50);
90 }
86 break; 91 break;
87 } 92 }
88 return { 93 const completion: Completion = {
89 label: entry.proposal, 94 label: entry.proposal,
90 detail: entry.description,
91 info: entry.documentation,
92 type: entry.kind?.toLowerCase(), 95 type: entry.kind?.toLowerCase(),
93 boost, 96 boost,
94 }; 97 };
98 if (entry.documentation !== undefined) {
99 completion.info = entry.documentation;
100 }
101 if (entry.description !== undefined) {
102 completion.detail = entry.description;
103 }
104 return completion;
95} 105}
96 106
97export class ContentAssistService { 107export default class ContentAssistService {
98 private readonly updateService: UpdateService; 108 private readonly updateService: UpdateService;
99 109
100 private lastCompletion: CompletionResult | null = null; 110 private lastCompletion: CompletionResult | null = null;
@@ -117,7 +127,7 @@ export class ContentAssistService {
117 options: [], 127 options: [],
118 }; 128 };
119 } 129 }
120 let range: { from: number, to: number }; 130 let range: { from: number; to: number };
121 let prefix = ''; 131 let prefix = '';
122 if (tokenBefore === null) { 132 if (tokenBefore === null) {
123 range = { 133 range = {
@@ -139,17 +149,20 @@ export class ContentAssistService {
139 log.trace('Returning cached completion result'); 149 log.trace('Returning cached completion result');
140 // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null` 150 // Postcondition of `shouldReturnCachedCompletion`: `lastCompletion !== null`
141 return { 151 return {
142 ...this.lastCompletion as CompletionResult, 152 ...(this.lastCompletion as CompletionResult),
143 ...range, 153 ...range,
144 }; 154 };
145 } 155 }
146 this.lastCompletion = null; 156 this.lastCompletion = null;
147 const entries = await this.updateService.fetchContentAssist({ 157 const entries = await this.updateService.fetchContentAssist(
148 resource: this.updateService.resourceName, 158 {
149 serviceType: 'assist', 159 resource: this.updateService.resourceName,
150 caretOffset: context.pos, 160 serviceType: 'assist',
151 proposalsLimit: PROPOSALS_LIMIT, 161 caretOffset: context.pos,
152 }, context); 162 proposalsLimit: PROPOSALS_LIMIT,
163 },
164 context,
165 );
153 if (context.aborted) { 166 if (context.aborted) {
154 return { 167 return {
155 ...range, 168 ...range,
@@ -175,7 +188,7 @@ export class ContentAssistService {
175 } 188 }
176 189
177 private shouldReturnCachedCompletion( 190 private shouldReturnCachedCompletion(
178 token: { from: number, to: number, text: string } | null, 191 token: { from: number; to: number; text: string } | null,
179 ): boolean { 192 ): boolean {
180 if (token === null || this.lastCompletion === null) { 193 if (token === null || this.lastCompletion === null) {
181 return false; 194 return false;
@@ -185,11 +198,16 @@ export class ContentAssistService {
185 if (!lastTo) { 198 if (!lastTo) {
186 return true; 199 return true;
187 } 200 }
188 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); 201 const [transformedFrom, transformedTo] = this.mapRangeInclusive(
189 return from >= transformedFrom 202 lastFrom,
190 && to <= transformedTo 203 lastTo,
191 && validFor instanceof RegExp 204 );
192 && validFor.exec(text) !== null; 205 return (
206 from >= transformedFrom &&
207 to <= transformedTo &&
208 validFor instanceof RegExp &&
209 validFor.exec(text) !== null
210 );
193 } 211 }
194 212
195 private shouldInvalidateCachedCompletion(transaction: Transaction): boolean { 213 private shouldInvalidateCachedCompletion(transaction: Transaction): boolean {
@@ -200,7 +218,10 @@ export class ContentAssistService {
200 if (!lastTo) { 218 if (!lastTo) {
201 return true; 219 return true;
202 } 220 }
203 const [transformedFrom, transformedTo] = this.mapRangeInclusive(lastFrom, lastTo); 221 const [transformedFrom, transformedTo] = this.mapRangeInclusive(
222 lastFrom,
223 lastTo,
224 );
204 let invalidate = false; 225 let invalidate = false;
205 transaction.changes.iterChangedRanges((fromA, toA) => { 226 transaction.changes.iterChangedRanges((fromA, toA) => {
206 if (fromA < transformedFrom || toA > transformedTo) { 227 if (fromA < transformedFrom || toA > transformedTo) {
@@ -210,7 +231,10 @@ export class ContentAssistService {
210 return invalidate; 231 return invalidate;
211 } 232 }
212 233
213 private mapRangeInclusive(lastFrom: number, lastTo: number): [number, number] { 234 private mapRangeInclusive(
235 lastFrom: number,
236 lastTo: number,
237 ): [number, number] {
214 const changes = this.updateService.computeChangesSinceLastUpdate(); 238 const changes = this.updateService.computeChangesSinceLastUpdate();
215 const transformedFrom = changes.mapPos(lastFrom); 239 const transformedFrom = changes.mapPos(lastFrom);
216 const transformedTo = changes.mapPos(lastTo, 1); 240 const transformedTo = changes.mapPos(lastTo, 1);
diff --git a/subprojects/frontend/src/xtext/HighlightingService.ts b/subprojects/frontend/src/xtext/HighlightingService.ts
index dfbb4a19..cf618b96 100644
--- a/subprojects/frontend/src/xtext/HighlightingService.ts
+++ b/subprojects/frontend/src/xtext/HighlightingService.ts
@@ -1,9 +1,10 @@
1import type { EditorStore } from '../editor/EditorStore'; 1import type EditorStore from '../editor/EditorStore';
2import type { IHighlightRange } from '../editor/semanticHighlighting'; 2import type { IHighlightRange } from '../editor/semanticHighlighting';
3import type { UpdateService } from './UpdateService'; 3
4import type UpdateService from './UpdateService';
4import { highlightingResult } from './xtextServiceResults'; 5import { highlightingResult } from './xtextServiceResults';
5 6
6export class HighlightingService { 7export default class HighlightingService {
7 private readonly store: EditorStore; 8 private readonly store: EditorStore;
8 9
9 private readonly updateService: UpdateService; 10 private readonly updateService: UpdateService;
diff --git a/subprojects/frontend/src/xtext/OccurrencesService.ts b/subprojects/frontend/src/xtext/OccurrencesService.ts
index bc865537..21fe8644 100644
--- a/subprojects/frontend/src/xtext/OccurrencesService.ts
+++ b/subprojects/frontend/src/xtext/OccurrencesService.ts
@@ -1,15 +1,16 @@
1import { Transaction } from '@codemirror/state'; 1import { Transaction } from '@codemirror/state';
2 2
3import type { EditorStore } from '../editor/EditorStore'; 3import type EditorStore from '../editor/EditorStore';
4import type { IOccurrence } from '../editor/findOccurrences'; 4import type { IOccurrence } from '../editor/findOccurrences';
5import type { UpdateService } from './UpdateService'; 5import Timer from '../utils/Timer';
6import { getLogger } from '../utils/logger'; 6import getLogger from '../utils/getLogger';
7import { Timer } from '../utils/Timer'; 7
8import { XtextWebSocketClient } from './XtextWebSocketClient'; 8import type UpdateService from './UpdateService';
9import type XtextWebSocketClient from './XtextWebSocketClient';
9import { 10import {
10 isConflictResult, 11 isConflictResult,
11 occurrencesResult, 12 OccurrencesResult,
12 TextRegion, 13 type TextRegion,
13} from './xtextServiceResults'; 14} from './xtextServiceResults';
14 15
15const FIND_OCCURRENCES_TIMEOUT_MS = 1000; 16const FIND_OCCURRENCES_TIMEOUT_MS = 1000;
@@ -33,7 +34,7 @@ function transformOccurrences(regions: TextRegion[]): IOccurrence[] {
33 return occurrences; 34 return occurrences;
34} 35}
35 36
36export class OccurrencesService { 37export default class OccurrencesService {
37 private readonly store: EditorStore; 38 private readonly store: EditorStore;
38 39
39 private readonly webSocketClient: XtextWebSocketClient; 40 private readonly webSocketClient: XtextWebSocketClient;
@@ -94,7 +95,7 @@ export class OccurrencesService {
94 this.findOccurrencesTimer.schedule(); 95 this.findOccurrencesTimer.schedule();
95 return; 96 return;
96 } 97 }
97 const parsedOccurrencesResult = occurrencesResult.safeParse(result); 98 const parsedOccurrencesResult = OccurrencesResult.safeParse(result);
98 if (!parsedOccurrencesResult.success) { 99 if (!parsedOccurrencesResult.success) {
99 log.error( 100 log.error(
100 'Unexpected occurences result', 101 'Unexpected occurences result',
@@ -107,14 +108,25 @@ export class OccurrencesService {
107 } 108 }
108 const { stateId, writeRegions, readRegions } = parsedOccurrencesResult.data; 109 const { stateId, writeRegions, readRegions } = parsedOccurrencesResult.data;
109 if (stateId !== this.updateService.xtextStateId) { 110 if (stateId !== this.updateService.xtextStateId) {
110 log.error('Unexpected state id, expected:', this.updateService.xtextStateId, 'got:', stateId); 111 log.error(
112 'Unexpected state id, expected:',
113 this.updateService.xtextStateId,
114 'got:',
115 stateId,
116 );
111 this.clearOccurrences(); 117 this.clearOccurrences();
112 return; 118 return;
113 } 119 }
114 const write = transformOccurrences(writeRegions); 120 const write = transformOccurrences(writeRegions);
115 const read = transformOccurrences(readRegions); 121 const read = transformOccurrences(readRegions);
116 this.hasOccurrences = write.length > 0 || read.length > 0; 122 this.hasOccurrences = write.length > 0 || read.length > 0;
117 log.debug('Found', write.length, 'write and', read.length, 'read occurrences'); 123 log.debug(
124 'Found',
125 write.length,
126 'write and',
127 read.length,
128 'read occurrences',
129 );
118 this.store.updateOccurrences(write, read); 130 this.store.updateOccurrences(write, read);
119 } 131 }
120 132
diff --git a/subprojects/frontend/src/xtext/UpdateService.ts b/subprojects/frontend/src/xtext/UpdateService.ts
index e78944a9..2994b11b 100644
--- a/subprojects/frontend/src/xtext/UpdateService.ts
+++ b/subprojects/frontend/src/xtext/UpdateService.ts
@@ -1,22 +1,23 @@
1import { 1import {
2 ChangeDesc, 2 type ChangeDesc,
3 ChangeSet, 3 ChangeSet,
4 ChangeSpec, 4 type ChangeSpec,
5 StateEffect, 5 StateEffect,
6 Transaction, 6 type Transaction,
7} from '@codemirror/state'; 7} from '@codemirror/state';
8import { nanoid } from 'nanoid'; 8import { nanoid } from 'nanoid';
9 9
10import type { EditorStore } from '../editor/EditorStore'; 10import type EditorStore from '../editor/EditorStore';
11import type { XtextWebSocketClient } from './XtextWebSocketClient'; 11import ConditionVariable from '../utils/ConditionVariable';
12import { ConditionVariable } from '../utils/ConditionVariable'; 12import Timer from '../utils/Timer';
13import { getLogger } from '../utils/logger'; 13import getLogger from '../utils/getLogger';
14import { Timer } from '../utils/Timer'; 14
15import type XtextWebSocketClient from './XtextWebSocketClient';
15import { 16import {
16 ContentAssistEntry, 17 type ContentAssistEntry,
17 contentAssistResult, 18 ContentAssistResult,
18 documentStateResult, 19 DocumentStateResult,
19 formattingResult, 20 FormattingResult,
20 isConflictResult, 21 isConflictResult,
21} from './xtextServiceResults'; 22} from './xtextServiceResults';
22 23
@@ -32,7 +33,7 @@ export interface IAbortSignal {
32 aborted: boolean; 33 aborted: boolean;
33} 34}
34 35
35export class UpdateService { 36export default class UpdateService {
36 resourceName: string; 37 resourceName: string;
37 38
38 xtextStateId: string | null = null; 39 xtextStateId: string | null = null;
@@ -76,8 +77,8 @@ export class UpdateService {
76 } 77 }
77 78
78 onTransaction(transaction: Transaction): void { 79 onTransaction(transaction: Transaction): void {
79 const setDirtyChangesEffect = transaction.effects.find( 80 const setDirtyChangesEffect = transaction.effects.find((effect) =>
80 (effect) => effect.is(setDirtyChanges), 81 effect.is(setDirtyChanges),
81 ) as StateEffect<ChangeSet> | undefined; 82 ) as StateEffect<ChangeSet> | undefined;
82 if (setDirtyChangesEffect) { 83 if (setDirtyChangesEffect) {
83 const { value } = setDirtyChangesEffect; 84 const { value } = setDirtyChangesEffect;
@@ -102,7 +103,10 @@ export class UpdateService {
102 * @return the summary of changes since the last update 103 * @return the summary of changes since the last update
103 */ 104 */
104 computeChangesSinceLastUpdate(): ChangeDesc { 105 computeChangesSinceLastUpdate(): ChangeDesc {
105 return this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) || this.dirtyChanges.desc; 106 return (
107 this.pendingUpdate?.composeDesc(this.dirtyChanges.desc) ||
108 this.dirtyChanges.desc
109 );
106 } 110 }
107 111
108 private handleIdleUpdate() { 112 private handleIdleUpdate() {
@@ -131,7 +135,7 @@ export class UpdateService {
131 serviceType: 'update', 135 serviceType: 'update',
132 fullText: this.store.state.doc.sliceString(0), 136 fullText: this.store.state.doc.sliceString(0),
133 }); 137 });
134 const { stateId } = documentStateResult.parse(result); 138 const { stateId } = DocumentStateResult.parse(result);
135 return [stateId, undefined]; 139 return [stateId, undefined];
136 } 140 }
137 141
@@ -158,7 +162,7 @@ export class UpdateService {
158 requiredStateId: this.xtextStateId, 162 requiredStateId: this.xtextStateId,
159 ...delta, 163 ...delta,
160 }); 164 });
161 const parsedDocumentStateResult = documentStateResult.safeParse(result); 165 const parsedDocumentStateResult = DocumentStateResult.safeParse(result);
162 if (parsedDocumentStateResult.success) { 166 if (parsedDocumentStateResult.success) {
163 return [parsedDocumentStateResult.data.stateId, undefined]; 167 return [parsedDocumentStateResult.data.stateId, undefined];
164 } 168 }
@@ -197,9 +201,10 @@ export class UpdateService {
197 requiredStateId: this.xtextStateId, 201 requiredStateId: this.xtextStateId,
198 ...delta, 202 ...delta,
199 }); 203 });
200 const parsedContentAssistResult = contentAssistResult.safeParse(result); 204 const parsedContentAssistResult = ContentAssistResult.safeParse(result);
201 if (parsedContentAssistResult.success) { 205 if (parsedContentAssistResult.success) {
202 const { stateId, entries: resultEntries } = parsedContentAssistResult.data; 206 const { stateId, entries: resultEntries } =
207 parsedContentAssistResult.data;
203 return [stateId, resultEntries]; 208 return [stateId, resultEntries];
204 } 209 }
205 if (isConflictResult(result, 'invalidStateId')) { 210 if (isConflictResult(result, 'invalidStateId')) {
@@ -223,14 +228,19 @@ export class UpdateService {
223 return this.doFetchContentAssist(params, this.xtextStateId as string); 228 return this.doFetchContentAssist(params, this.xtextStateId as string);
224 } 229 }
225 230
226 private async doFetchContentAssist(params: Record<string, unknown>, expectedStateId: string) { 231 private async doFetchContentAssist(
232 params: Record<string, unknown>,
233 expectedStateId: string,
234 ) {
227 const result = await this.webSocketClient.send({ 235 const result = await this.webSocketClient.send({
228 ...params, 236 ...params,
229 requiredStateId: expectedStateId, 237 requiredStateId: expectedStateId,
230 }); 238 });
231 const { stateId, entries } = contentAssistResult.parse(result); 239 const { stateId, entries } = ContentAssistResult.parse(result);
232 if (stateId !== expectedStateId) { 240 if (stateId !== expectedStateId) {
233 throw new Error(`Unexpected state id, expected: ${expectedStateId} got: ${stateId}`); 241 throw new Error(
242 `Unexpected state id, expected: ${expectedStateId} got: ${stateId}`,
243 );
234 } 244 }
235 return entries; 245 return entries;
236 } 246 }
@@ -250,7 +260,7 @@ export class UpdateService {
250 selectionStart: from, 260 selectionStart: from,
251 selectionEnd: to, 261 selectionEnd: to,
252 }); 262 });
253 const { stateId, formattedText } = formattingResult.parse(result); 263 const { stateId, formattedText } = FormattingResult.parse(result);
254 this.applyBeforeDirtyChanges({ 264 this.applyBeforeDirtyChanges({
255 from, 265 from,
256 to, 266 to,
@@ -282,16 +292,15 @@ export class UpdateService {
282 } 292 }
283 293
284 private applyBeforeDirtyChanges(changeSpec: ChangeSpec) { 294 private applyBeforeDirtyChanges(changeSpec: ChangeSpec) {
285 const pendingChanges = this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges; 295 const pendingChanges =
296 this.pendingUpdate?.compose(this.dirtyChanges) || this.dirtyChanges;
286 const revertChanges = pendingChanges.invert(this.store.state.doc); 297 const revertChanges = pendingChanges.invert(this.store.state.doc);
287 const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength); 298 const applyBefore = ChangeSet.of(changeSpec, revertChanges.newLength);
288 const redoChanges = pendingChanges.map(applyBefore.desc); 299 const redoChanges = pendingChanges.map(applyBefore.desc);
289 const changeSet = revertChanges.compose(applyBefore).compose(redoChanges); 300 const changeSet = revertChanges.compose(applyBefore).compose(redoChanges);
290 this.store.dispatch({ 301 this.store.dispatch({
291 changes: changeSet, 302 changes: changeSet,
292 effects: [ 303 effects: [setDirtyChanges.of(redoChanges)],
293 setDirtyChanges.of(redoChanges),
294 ],
295 }); 304 });
296 } 305 }
297 306
@@ -316,7 +325,9 @@ export class UpdateService {
316 * @param callback the asynchronous callback that updates the server state 325 * @param callback the asynchronous callback that updates the server state
317 * @return a promise resolving to the second value returned by `callback` 326 * @return a promise resolving to the second value returned by `callback`
318 */ 327 */
319 private async withUpdate<T>(callback: () => Promise<[string, T]>): Promise<T> { 328 private async withUpdate<T>(
329 callback: () => Promise<[string, T]>,
330 ): Promise<T> {
320 if (this.pendingUpdate !== null) { 331 if (this.pendingUpdate !== null) {
321 throw new Error('Another update is pending, will not perform update'); 332 throw new Error('Another update is pending, will not perform update');
322 } 333 }
diff --git a/subprojects/frontend/src/xtext/ValidationService.ts b/subprojects/frontend/src/xtext/ValidationService.ts
index ff7d3700..a0b27251 100644
--- a/subprojects/frontend/src/xtext/ValidationService.ts
+++ b/subprojects/frontend/src/xtext/ValidationService.ts
@@ -1,10 +1,11 @@
1import type { Diagnostic } from '@codemirror/lint'; 1import type { Diagnostic } from '@codemirror/lint';
2 2
3import type { EditorStore } from '../editor/EditorStore'; 3import type EditorStore from '../editor/EditorStore';
4import type { UpdateService } from './UpdateService';
5import { validationResult } from './xtextServiceResults';
6 4
7export class ValidationService { 5import type UpdateService from './UpdateService';
6import { ValidationResult } from './xtextServiceResults';
7
8export default class ValidationService {
8 private readonly store: EditorStore; 9 private readonly store: EditorStore;
9 10
10 private readonly updateService: UpdateService; 11 private readonly updateService: UpdateService;
@@ -15,15 +16,10 @@ export class ValidationService {
15 } 16 }
16 17
17 onPush(push: unknown): void { 18 onPush(push: unknown): void {
18 const { issues } = validationResult.parse(push); 19 const { issues } = ValidationResult.parse(push);
19 const allChanges = this.updateService.computeChangesSinceLastUpdate(); 20 const allChanges = this.updateService.computeChangesSinceLastUpdate();
20 const diagnostics: Diagnostic[] = []; 21 const diagnostics: Diagnostic[] = [];
21 issues.forEach(({ 22 issues.forEach(({ offset, length, severity, description }) => {
22 offset,
23 length,
24 severity,
25 description,
26 }) => {
27 if (severity === 'ignore') { 23 if (severity === 'ignore') {
28 return; 24 return;
29 } 25 }
diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts
index 0898e725..7297c674 100644
--- a/subprojects/frontend/src/xtext/XtextClient.ts
+++ b/subprojects/frontend/src/xtext/XtextClient.ts
@@ -4,19 +4,20 @@ import type {
4} from '@codemirror/autocomplete'; 4} from '@codemirror/autocomplete';
5import type { Transaction } from '@codemirror/state'; 5import type { Transaction } from '@codemirror/state';
6 6
7import type { EditorStore } from '../editor/EditorStore'; 7import type EditorStore from '../editor/EditorStore';
8import { ContentAssistService } from './ContentAssistService'; 8import getLogger from '../utils/getLogger';
9import { HighlightingService } from './HighlightingService'; 9
10import { OccurrencesService } from './OccurrencesService'; 10import ContentAssistService from './ContentAssistService';
11import { UpdateService } from './UpdateService'; 11import HighlightingService from './HighlightingService';
12import { getLogger } from '../utils/logger'; 12import OccurrencesService from './OccurrencesService';
13import { ValidationService } from './ValidationService'; 13import UpdateService from './UpdateService';
14import { XtextWebSocketClient } from './XtextWebSocketClient'; 14import ValidationService from './ValidationService';
15import { XtextWebPushService } from './xtextMessages'; 15import XtextWebSocketClient from './XtextWebSocketClient';
16import type { XtextWebPushService } from './xtextMessages';
16 17
17const log = getLogger('xtext.XtextClient'); 18const log = getLogger('xtext.XtextClient');
18 19
19export class XtextClient { 20export default class XtextClient {
20 private readonly webSocketClient: XtextWebSocketClient; 21 private readonly webSocketClient: XtextWebSocketClient;
21 22
22 private readonly updateService: UpdateService; 23 private readonly updateService: UpdateService;
@@ -32,11 +33,15 @@ export class XtextClient {
32 constructor(store: EditorStore) { 33 constructor(store: EditorStore) {
33 this.webSocketClient = new XtextWebSocketClient( 34 this.webSocketClient = new XtextWebSocketClient(
34 () => this.updateService.onReconnect(), 35 () => this.updateService.onReconnect(),
35 (resource, stateId, service, push) => this.onPush(resource, stateId, service, push), 36 (resource, stateId, service, push) =>
37 this.onPush(resource, stateId, service, push),
36 ); 38 );
37 this.updateService = new UpdateService(store, this.webSocketClient); 39 this.updateService = new UpdateService(store, this.webSocketClient);
38 this.contentAssistService = new ContentAssistService(this.updateService); 40 this.contentAssistService = new ContentAssistService(this.updateService);
39 this.highlightingService = new HighlightingService(store, this.updateService); 41 this.highlightingService = new HighlightingService(
42 store,
43 this.updateService,
44 );
40 this.validationService = new ValidationService(store, this.updateService); 45 this.validationService = new ValidationService(store, this.updateService);
41 this.occurrencesService = new OccurrencesService( 46 this.occurrencesService = new OccurrencesService(
42 store, 47 store,
@@ -53,14 +58,29 @@ export class XtextClient {
53 this.occurrencesService.onTransaction(transaction); 58 this.occurrencesService.onTransaction(transaction);
54 } 59 }
55 60
56 private onPush(resource: string, stateId: string, service: XtextWebPushService, push: unknown) { 61 private onPush(
62 resource: string,
63 stateId: string,
64 service: XtextWebPushService,
65 push: unknown,
66 ) {
57 const { resourceName, xtextStateId } = this.updateService; 67 const { resourceName, xtextStateId } = this.updateService;
58 if (resource !== resourceName) { 68 if (resource !== resourceName) {
59 log.error('Unknown resource name: expected:', resourceName, 'got:', resource); 69 log.error(
70 'Unknown resource name: expected:',
71 resourceName,
72 'got:',
73 resource,
74 );
60 return; 75 return;
61 } 76 }
62 if (stateId !== xtextStateId) { 77 if (stateId !== xtextStateId) {
63 log.error('Unexpected xtext state id: expected:', xtextStateId, 'got:', stateId); 78 log.error(
79 'Unexpected xtext state id: expected:',
80 xtextStateId,
81 'got:',
82 stateId,
83 );
64 // The current push message might be stale (referring to a previous state), 84 // The current push message might be stale (referring to a previous state),
65 // so this is not neccessarily an error and there is no need to force-reconnect. 85 // so this is not neccessarily an error and there is no need to force-reconnect.
66 return; 86 return;
@@ -71,6 +91,9 @@ export class XtextClient {
71 return; 91 return;
72 case 'validate': 92 case 'validate':
73 this.validationService.onPush(push); 93 this.validationService.onPush(push);
94 return;
95 default:
96 throw new Error('Unknown service');
74 } 97 }
75 } 98 }
76 99
diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
index 2ce20a54..ceb1f3fd 100644
--- a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
+++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
@@ -1,16 +1,17 @@
1import { nanoid } from 'nanoid'; 1import { nanoid } from 'nanoid';
2 2
3import { getLogger } from '../utils/logger'; 3import PendingTask from '../utils/PendingTask';
4import { PendingTask } from '../utils/PendingTask'; 4import Timer from '../utils/Timer';
5import { Timer } from '../utils/Timer'; 5import getLogger from '../utils/getLogger';
6
6import { 7import {
7 xtextWebErrorResponse, 8 XtextWebErrorResponse,
8 XtextWebRequest, 9 XtextWebRequest,
9 xtextWebOkResponse, 10 XtextWebOkResponse,
10 xtextWebPushMessage, 11 XtextWebPushMessage,
11 XtextWebPushService, 12 XtextWebPushService,
12} from './xtextMessages'; 13} from './xtextMessages';
13import { pongResult } from './xtextServiceResults'; 14import { PongResult } from './xtextServiceResults';
14 15
15const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; 16const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
16 17
@@ -18,7 +19,8 @@ const WEBSOCKET_CLOSE_OK = 1000;
18 19
19const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000]; 20const RECONNECT_DELAY_MS = [200, 1000, 5000, 30_000];
20 21
21const MAX_RECONNECT_DELAY_MS = RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1]; 22const MAX_RECONNECT_DELAY_MS =
23 RECONNECT_DELAY_MS[RECONNECT_DELAY_MS.length - 1];
22 24
23const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000; 25const BACKGROUND_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
24 26
@@ -47,7 +49,7 @@ enum State {
47 TimedOut, 49 TimedOut,
48} 50}
49 51
50export class XtextWebSocketClient { 52export default class XtextWebSocketClient {
51 private nextMessageId = 0; 53 private nextMessageId = 0;
52 54
53 private connection!: WebSocket; 55 private connection!: WebSocket;
@@ -88,9 +90,11 @@ export class XtextWebSocketClient {
88 } 90 }
89 91
90 get isOpen(): boolean { 92 get isOpen(): boolean {
91 return this.state === State.TabVisible 93 return (
92 || this.state === State.TabHiddenIdle 94 this.state === State.TabVisible ||
93 || this.state === State.TabHiddenWaiting; 95 this.state === State.TabHiddenIdle ||
96 this.state === State.TabHiddenWaiting
97 );
94 } 98 }
95 99
96 private reconnect() { 100 private reconnect() {
@@ -104,7 +108,11 @@ export class XtextWebSocketClient {
104 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1); 108 this.connection = new WebSocket(webSocketUrl, XTEXT_SUBPROTOCOL_V1);
105 this.connection.addEventListener('open', () => { 109 this.connection.addEventListener('open', () => {
106 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) { 110 if (this.connection.protocol !== XTEXT_SUBPROTOCOL_V1) {
107 log.error('Unknown subprotocol', this.connection.protocol, 'selected by server'); 111 log.error(
112 'Unknown subprotocol',
113 this.connection.protocol,
114 'selected by server',
115 );
108 this.forceReconnectOnError(); 116 this.forceReconnectOnError();
109 } 117 }
110 if (document.visibilityState === 'hidden') { 118 if (document.visibilityState === 'hidden') {
@@ -126,8 +134,11 @@ export class XtextWebSocketClient {
126 this.handleMessage(event.data); 134 this.handleMessage(event.data);
127 }); 135 });
128 this.connection.addEventListener('close', (event) => { 136 this.connection.addEventListener('close', (event) => {
129 if (this.isLogicallyClosed && event.code === WEBSOCKET_CLOSE_OK 137 if (
130 && this.pendingRequests.size === 0) { 138 this.isLogicallyClosed &&
139 event.code === WEBSOCKET_CLOSE_OK &&
140 this.pendingRequests.size === 0
141 ) {
131 log.info('Websocket closed'); 142 log.info('Websocket closed');
132 return; 143 return;
133 } 144 }
@@ -144,7 +155,10 @@ export class XtextWebSocketClient {
144 return; 155 return;
145 } 156 }
146 this.idleTimer.cancel(); 157 this.idleTimer.cancel();
147 if (this.state === State.TabHiddenIdle || this.state === State.TabHiddenWaiting) { 158 if (
159 this.state === State.TabHiddenIdle ||
160 this.state === State.TabHiddenWaiting
161 ) {
148 this.handleTabVisibleConnected(); 162 this.handleTabVisibleConnected();
149 return; 163 return;
150 } 164 }
@@ -183,7 +197,11 @@ export class XtextWebSocketClient {
183 this.closeConnection(1000, 'idle timeout'); 197 this.closeConnection(1000, 'idle timeout');
184 return; 198 return;
185 } 199 }
186 log.info('Waiting for', pending, 'pending requests before closing websocket'); 200 log.info(
201 'Waiting for',
202 pending,
203 'pending requests before closing websocket',
204 );
187 } 205 }
188 206
189 private sendPing() { 207 private sendPing() {
@@ -192,19 +210,21 @@ export class XtextWebSocketClient {
192 } 210 }
193 const ping = nanoid(); 211 const ping = nanoid();
194 log.trace('Ping', ping); 212 log.trace('Ping', ping);
195 this.send({ ping }).then((result) => { 213 this.send({ ping })
196 const parsedPongResult = pongResult.safeParse(result); 214 .then((result) => {
197 if (parsedPongResult.success && parsedPongResult.data.pong === ping) { 215 const parsedPongResult = PongResult.safeParse(result);
198 log.trace('Pong', ping); 216 if (parsedPongResult.success && parsedPongResult.data.pong === ping) {
199 this.pingTimer.schedule(); 217 log.trace('Pong', ping);
200 } else { 218 this.pingTimer.schedule();
201 log.error('Invalid pong:', parsedPongResult, 'expected:', ping); 219 } else {
220 log.error('Invalid pong:', parsedPongResult, 'expected:', ping);
221 this.forceReconnectOnError();
222 }
223 })
224 .catch((error) => {
225 log.error('Error while waiting for ping', error);
202 this.forceReconnectOnError(); 226 this.forceReconnectOnError();
203 } 227 });
204 }).catch((error) => {
205 log.error('Error while waiting for ping', error);
206 this.forceReconnectOnError();
207 });
208 } 228 }
209 229
210 send(request: unknown): Promise<unknown> { 230 send(request: unknown): Promise<unknown> {
@@ -250,13 +270,13 @@ export class XtextWebSocketClient {
250 this.forceReconnectOnError(); 270 this.forceReconnectOnError();
251 return; 271 return;
252 } 272 }
253 const okResponse = xtextWebOkResponse.safeParse(message); 273 const okResponse = XtextWebOkResponse.safeParse(message);
254 if (okResponse.success) { 274 if (okResponse.success) {
255 const { id, response } = okResponse.data; 275 const { id, response } = okResponse.data;
256 this.resolveRequest(id, response); 276 this.resolveRequest(id, response);
257 return; 277 return;
258 } 278 }
259 const errorResponse = xtextWebErrorResponse.safeParse(message); 279 const errorResponse = XtextWebErrorResponse.safeParse(message);
260 if (errorResponse.success) { 280 if (errorResponse.success) {
261 const { id, error, message: errorMessage } = errorResponse.data; 281 const { id, error, message: errorMessage } = errorResponse.data;
262 this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`)); 282 this.rejectRequest(id, new Error(`${error} error: ${errorMessage}`));
@@ -266,14 +286,9 @@ export class XtextWebSocketClient {
266 } 286 }
267 return; 287 return;
268 } 288 }
269 const pushMessage = xtextWebPushMessage.safeParse(message); 289 const pushMessage = XtextWebPushMessage.safeParse(message);
270 if (pushMessage.success) { 290 if (pushMessage.success) {
271 const { 291 const { resource, stateId, service, push } = pushMessage.data;
272 resource,
273 stateId,
274 service,
275 push,
276 } = pushMessage.data;
277 this.onPush(resource, stateId, service, push); 292 this.onPush(resource, stateId, service, push);
278 } else { 293 } else {
279 log.error( 294 log.error(
@@ -343,7 +358,8 @@ export class XtextWebSocketClient {
343 private handleErrorState() { 358 private handleErrorState() {
344 this.state = State.Error; 359 this.state = State.Error;
345 this.reconnectTryCount += 1; 360 this.reconnectTryCount += 1;
346 const delay = RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS; 361 const delay =
362 RECONNECT_DELAY_MS[this.reconnectTryCount - 1] || MAX_RECONNECT_DELAY_MS;
347 log.info('Reconnecting in', delay, 'ms'); 363 log.info('Reconnecting in', delay, 'ms');
348 this.reconnectTimer.schedule(delay); 364 this.reconnectTimer.schedule(delay);
349 } 365 }
diff --git a/subprojects/frontend/src/xtext/xtextMessages.ts b/subprojects/frontend/src/xtext/xtextMessages.ts
index 4bf49c17..c4d0c676 100644
--- a/subprojects/frontend/src/xtext/xtextMessages.ts
+++ b/subprojects/frontend/src/xtext/xtextMessages.ts
@@ -1,40 +1,42 @@
1/* eslint-disable @typescript-eslint/no-redeclare -- Declare types with their companion objects */
2
1import { z } from 'zod'; 3import { z } from 'zod';
2 4
3export const xtextWebRequest = z.object({ 5export const XtextWebRequest = z.object({
4 id: z.string().min(1), 6 id: z.string().min(1),
5 request: z.unknown(), 7 request: z.unknown(),
6}); 8});
7 9
8export type XtextWebRequest = z.infer<typeof xtextWebRequest>; 10export type XtextWebRequest = z.infer<typeof XtextWebRequest>;
9 11
10export const xtextWebOkResponse = z.object({ 12export const XtextWebOkResponse = z.object({
11 id: z.string().min(1), 13 id: z.string().min(1),
12 response: z.unknown(), 14 response: z.unknown(),
13}); 15});
14 16
15export type XtextWebOkResponse = z.infer<typeof xtextWebOkResponse>; 17export type XtextWebOkResponse = z.infer<typeof XtextWebOkResponse>;
16 18
17export const xtextWebErrorKind = z.enum(['request', 'server']); 19export const XtextWebErrorKind = z.enum(['request', 'server']);
18 20
19export type XtextWebErrorKind = z.infer<typeof xtextWebErrorKind>; 21export type XtextWebErrorKind = z.infer<typeof XtextWebErrorKind>;
20 22
21export const xtextWebErrorResponse = z.object({ 23export const XtextWebErrorResponse = z.object({
22 id: z.string().min(1), 24 id: z.string().min(1),
23 error: xtextWebErrorKind, 25 error: XtextWebErrorKind,
24 message: z.string(), 26 message: z.string(),
25}); 27});
26 28
27export type XtextWebErrorResponse = z.infer<typeof xtextWebErrorResponse>; 29export type XtextWebErrorResponse = z.infer<typeof XtextWebErrorResponse>;
28 30
29export const xtextWebPushService = z.enum(['highlight', 'validate']); 31export const XtextWebPushService = z.enum(['highlight', 'validate']);
30 32
31export type XtextWebPushService = z.infer<typeof xtextWebPushService>; 33export type XtextWebPushService = z.infer<typeof XtextWebPushService>;
32 34
33export const xtextWebPushMessage = z.object({ 35export const XtextWebPushMessage = z.object({
34 resource: z.string().min(1), 36 resource: z.string().min(1),
35 stateId: z.string().min(1), 37 stateId: z.string().min(1),
36 service: xtextWebPushService, 38 service: XtextWebPushService,
37 push: z.unknown(), 39 push: z.unknown(),
38}); 40});
39 41
40export type XtextWebPushMessage = z.infer<typeof xtextWebPushMessage>; 42export type XtextWebPushMessage = z.infer<typeof XtextWebPushMessage>;
diff --git a/subprojects/frontend/src/xtext/xtextServiceResults.ts b/subprojects/frontend/src/xtext/xtextServiceResults.ts
index 8b0dbbfb..4cfb9c33 100644
--- a/subprojects/frontend/src/xtext/xtextServiceResults.ts
+++ b/subprojects/frontend/src/xtext/xtextServiceResults.ts
@@ -1,112 +1,120 @@
1/* eslint-disable @typescript-eslint/no-redeclare -- Declare types with their companion objects */
2
1import { z } from 'zod'; 3import { z } from 'zod';
2 4
3export const pongResult = z.object({ 5export const PongResult = z.object({
4 pong: z.string().min(1), 6 pong: z.string().min(1),
5}); 7});
6 8
7export type PongResult = z.infer<typeof pongResult>; 9export type PongResult = z.infer<typeof PongResult>;
8 10
9export const documentStateResult = z.object({ 11export const DocumentStateResult = z.object({
10 stateId: z.string().min(1), 12 stateId: z.string().min(1),
11}); 13});
12 14
13export type DocumentStateResult = z.infer<typeof documentStateResult>; 15export type DocumentStateResult = z.infer<typeof DocumentStateResult>;
14 16
15export const conflict = z.enum(['invalidStateId', 'canceled']); 17export const Conflict = z.enum(['invalidStateId', 'canceled']);
16 18
17export type Conflict = z.infer<typeof conflict>; 19export type Conflict = z.infer<typeof Conflict>;
18 20
19export const serviceConflictResult = z.object({ 21export const ServiceConflictResult = z.object({
20 conflict, 22 conflict: Conflict,
21}); 23});
22 24
23export type ServiceConflictResult = z.infer<typeof serviceConflictResult>; 25export type ServiceConflictResult = z.infer<typeof ServiceConflictResult>;
24 26
25export function isConflictResult(result: unknown, conflictType: Conflict): boolean { 27export function isConflictResult(
26 const parsedConflictResult = serviceConflictResult.safeParse(result); 28 result: unknown,
27 return parsedConflictResult.success && parsedConflictResult.data.conflict === conflictType; 29 conflictType: Conflict,
30): boolean {
31 const parsedConflictResult = ServiceConflictResult.safeParse(result);
32 return (
33 parsedConflictResult.success &&
34 parsedConflictResult.data.conflict === conflictType
35 );
28} 36}
29 37
30export const severity = z.enum(['error', 'warning', 'info', 'ignore']); 38export const Severity = z.enum(['error', 'warning', 'info', 'ignore']);
31 39
32export type Severity = z.infer<typeof severity>; 40export type Severity = z.infer<typeof Severity>;
33 41
34export const issue = z.object({ 42export const Issue = z.object({
35 description: z.string().min(1), 43 description: z.string().min(1),
36 severity, 44 severity: Severity,
37 line: z.number().int(), 45 line: z.number().int(),
38 column: z.number().int().nonnegative(), 46 column: z.number().int().nonnegative(),
39 offset: z.number().int().nonnegative(), 47 offset: z.number().int().nonnegative(),
40 length: z.number().int().nonnegative(), 48 length: z.number().int().nonnegative(),
41}); 49});
42 50
43export type Issue = z.infer<typeof issue>; 51export type Issue = z.infer<typeof Issue>;
44 52
45export const validationResult = z.object({ 53export const ValidationResult = z.object({
46 issues: issue.array(), 54 issues: Issue.array(),
47}); 55});
48 56
49export type ValidationResult = z.infer<typeof validationResult>; 57export type ValidationResult = z.infer<typeof ValidationResult>;
50 58
51export const replaceRegion = z.object({ 59export const ReplaceRegion = z.object({
52 offset: z.number().int().nonnegative(), 60 offset: z.number().int().nonnegative(),
53 length: z.number().int().nonnegative(), 61 length: z.number().int().nonnegative(),
54 text: z.string(), 62 text: z.string(),
55}); 63});
56 64
57export type ReplaceRegion = z.infer<typeof replaceRegion>; 65export type ReplaceRegion = z.infer<typeof ReplaceRegion>;
58 66
59export const textRegion = z.object({ 67export const TextRegion = z.object({
60 offset: z.number().int().nonnegative(), 68 offset: z.number().int().nonnegative(),
61 length: z.number().int().nonnegative(), 69 length: z.number().int().nonnegative(),
62}); 70});
63 71
64export type TextRegion = z.infer<typeof textRegion>; 72export type TextRegion = z.infer<typeof TextRegion>;
65 73
66export const contentAssistEntry = z.object({ 74export const ContentAssistEntry = z.object({
67 prefix: z.string(), 75 prefix: z.string(),
68 proposal: z.string().min(1), 76 proposal: z.string().min(1),
69 label: z.string().optional(), 77 label: z.string().optional(),
70 description: z.string().min(1).optional(), 78 description: z.string().min(1).optional(),
71 documentation: z.string().min(1).optional(), 79 documentation: z.string().min(1).optional(),
72 escapePosition: z.number().int().nonnegative().optional(), 80 escapePosition: z.number().int().nonnegative().optional(),
73 textReplacements: replaceRegion.array(), 81 textReplacements: ReplaceRegion.array(),
74 editPositions: textRegion.array(), 82 editPositions: TextRegion.array(),
75 kind: z.string().min(1), 83 kind: z.string().min(1),
76}); 84});
77 85
78export type ContentAssistEntry = z.infer<typeof contentAssistEntry>; 86export type ContentAssistEntry = z.infer<typeof ContentAssistEntry>;
79 87
80export const contentAssistResult = documentStateResult.extend({ 88export const ContentAssistResult = DocumentStateResult.extend({
81 entries: contentAssistEntry.array(), 89 entries: ContentAssistEntry.array(),
82}); 90});
83 91
84export type ContentAssistResult = z.infer<typeof contentAssistResult>; 92export type ContentAssistResult = z.infer<typeof ContentAssistResult>;
85 93
86export const highlightingRegion = z.object({ 94export const HighlightingRegion = z.object({
87 offset: z.number().int().nonnegative(), 95 offset: z.number().int().nonnegative(),
88 length: z.number().int().nonnegative(), 96 length: z.number().int().nonnegative(),
89 styleClasses: z.string().min(1).array(), 97 styleClasses: z.string().min(1).array(),
90}); 98});
91 99
92export type HighlightingRegion = z.infer<typeof highlightingRegion>; 100export type HighlightingRegion = z.infer<typeof HighlightingRegion>;
93 101
94export const highlightingResult = z.object({ 102export const highlightingResult = z.object({
95 regions: highlightingRegion.array(), 103 regions: HighlightingRegion.array(),
96}); 104});
97 105
98export type HighlightingResult = z.infer<typeof highlightingResult>; 106export type HighlightingResult = z.infer<typeof highlightingResult>;
99 107
100export const occurrencesResult = documentStateResult.extend({ 108export const OccurrencesResult = DocumentStateResult.extend({
101 writeRegions: textRegion.array(), 109 writeRegions: TextRegion.array(),
102 readRegions: textRegion.array(), 110 readRegions: TextRegion.array(),
103}); 111});
104 112
105export type OccurrencesResult = z.infer<typeof occurrencesResult>; 113export type OccurrencesResult = z.infer<typeof OccurrencesResult>;
106 114
107export const formattingResult = documentStateResult.extend({ 115export const FormattingResult = DocumentStateResult.extend({
108 formattedText: z.string(), 116 formattedText: z.string(),
109 replaceRegion: textRegion, 117 replaceRegion: TextRegion,
110}); 118});
111 119
112export type FormattingResult = z.infer<typeof formattingResult>; 120export type FormattingResult = z.infer<typeof FormattingResult>;
diff --git a/subprojects/frontend/tsconfig.sonar.json b/subprojects/frontend/tsconfig.base.json
index 9db12b91..e33e330e 100644
--- a/subprojects/frontend/tsconfig.sonar.json
+++ b/subprojects/frontend/tsconfig.base.json
@@ -1,16 +1,15 @@
1{ 1{
2 "compilerOptions": { 2 "compilerOptions": {
3 "target": "es2020", 3 "target": "ESNext",
4 "module": "esnext", 4 "module": "ESNext",
5 "moduleResolution": "node", 5 "moduleResolution": "Node",
6 "esModuleInterop": true, 6 "esModuleInterop": true,
7 "allowSyntheticDefaultImports": true, 7 "allowSyntheticDefaultImports": true,
8 "jsx": "react",
9 "strict": true, 8 "strict": true,
10 "noImplicitOverride": true, 9 "noImplicitOverride": true,
11 "noImplicitReturns": true, 10 "noImplicitReturns": true,
12 "noEmit": true, 11 "exactOptionalPropertyTypes": true,
12 "isolatedModules": true,
13 "skipLibCheck": true 13 "skipLibCheck": true
14 }, 14 }
15 "include": ["./src/**/*"]
16} 15}
diff --git a/subprojects/frontend/tsconfig.json b/subprojects/frontend/tsconfig.json
index 94c357c5..fcde9939 100644
--- a/subprojects/frontend/tsconfig.json
+++ b/subprojects/frontend/tsconfig.json
@@ -1,18 +1,17 @@
1{ 1{
2 "extends": "./tsconfig.base.json",
2 "compilerOptions": { 3 "compilerOptions": {
3 "target": "es2020",
4 "module": "esnext",
5 "moduleResolution": "node",
6 "esModuleInterop": true,
7 "allowSyntheticDefaultImports": true,
8 "jsx": "react", 4 "jsx": "react",
9 "strict": true,
10 "noImplicitOverride": true,
11 "noImplicitReturns": true,
12 "exactOptionalPropertyTypes": false,
13 "noEmit": true, 5 "noEmit": true,
14 "skipLibCheck": true 6 "lib": ["DOM", "DOM.Iterable", "ESNext"],
7 "types": ["vite/client"]
15 }, 8 },
16 "include": ["./src/**/*"], 9 "include": [
17 "exclude": ["./build/generated/sources/lezer/*"] 10 "src",
11 "types"
12 ],
13 "exclude": ["types/node"],
14 "references": [
15 { "path": "./tsconfig.node.json" }
16 ]
18} 17}
diff --git a/subprojects/frontend/tsconfig.node.json b/subprojects/frontend/tsconfig.node.json
new file mode 100644
index 00000000..f5d6e6ec
--- /dev/null
+++ b/subprojects/frontend/tsconfig.node.json
@@ -0,0 +1,17 @@
1{
2 "extends": "./tsconfig.base.json",
3 "compilerOptions": {
4 "composite": true,
5 "checkJs": true,
6 "lib": ["ESNext"],
7 "types": ["node"],
8 "emitDeclarationOnly": true,
9 "outDir": "build/typescript"
10 },
11 "include": [
12 ".eslintrc.cjs",
13 "prettier.config.cjs",
14 "types/node",
15 "vite.config.ts"
16 ]
17}
diff --git a/subprojects/frontend/types/ImportMeta.d.ts b/subprojects/frontend/types/ImportMeta.d.ts
new file mode 100644
index 00000000..2008e268
--- /dev/null
+++ b/subprojects/frontend/types/ImportMeta.d.ts
@@ -0,0 +1,9 @@
1interface ImportMeta {
2 env: {
3 DEV: boolean;
4 MODE: string;
5 PROD: boolean;
6 VITE_PACKAGE_NAME: string;
7 VITE_PACKAGE_VERSION: string;
8 };
9}
diff --git a/subprojects/frontend/types/grammar.d.ts b/subprojects/frontend/types/grammar.d.ts
new file mode 100644
index 00000000..1480085b
--- /dev/null
+++ b/subprojects/frontend/types/grammar.d.ts
@@ -0,0 +1,5 @@
1declare module '*.grammar' {
2 import type { LRParser } from '@lezer/lr';
3
4 export const parser: LRParser;
5}
diff --git a/subprojects/frontend/types/node/@lezer-generator-rollup.d.ts b/subprojects/frontend/types/node/@lezer-generator-rollup.d.ts
new file mode 100644
index 00000000..dea39ec9
--- /dev/null
+++ b/subprojects/frontend/types/node/@lezer-generator-rollup.d.ts
@@ -0,0 +1,7 @@
1// We have to explicitly redeclare the type of the `./rollup` ESM export of `@lezer/generator`,
2// because TypeScript can't find it on its own even with `"moduleResolution": "Node16"`.
3declare module '@lezer/generator/rollup' {
4 import type { Plugin } from 'rollup';
5
6 export function lezer(): Plugin;
7}
diff --git a/subprojects/frontend/vite.config.ts b/subprojects/frontend/vite.config.ts
new file mode 100644
index 00000000..9cb426cf
--- /dev/null
+++ b/subprojects/frontend/vite.config.ts
@@ -0,0 +1,92 @@
1import { readFileSync } from 'node:fs';
2import path from 'node:path';
3import { fileURLToPath } from 'node:url';
4
5import { lezer } from '@lezer/generator/rollup';
6import react from '@vitejs/plugin-react';
7import { defineConfig } from 'vite';
8import injectPreload from 'vite-plugin-inject-preload';
9
10const thisDir = path.dirname(fileURLToPath(import.meta.url));
11
12const mode = process.env.MODE || 'development';
13const isDevelopment = mode === 'development';
14
15function portNumberOrElse(envName: string, fallback: number): number {
16 const value = process.env[envName];
17 return value ? parseInt(value, 10) : fallback;
18}
19
20const listenHost = process.env.LISTEN_HOST || 'localhost';
21const listenPort = portNumberOrElse('LISTEN_PORT', 1313);
22const apiHost = process.env.API_HOST || listenHost;
23const apiPort = portNumberOrElse('API_PORT', 1312);
24const apiSecure = apiPort === 443;
25const publicHost = process.env.PUBLIC_HOST || listenHost;
26const publicPort = portNumberOrElse('PUBLIC_PORT', listenPort);
27
28const { name: packageName, version: packageVersion } = JSON.parse(
29 readFileSync(path.join(thisDir, 'package.json'), 'utf8'),
30) as { name: string; version: string };
31process.env.VITE_PACKAGE_NAME ??= packageName;
32process.env.VITE_PACKAGE_VERSIOn ??= packageVersion;
33
34export default defineConfig({
35 logLevel: 'info',
36 mode,
37 root: thisDir,
38 cacheDir: path.join(thisDir, 'build/vite/cache'),
39 plugins: [
40 react({
41 babel: {
42 // Gets rid of deoptimization warnings for large chunks.
43 // We don't need to minify here, because the output of Babel
44 // will get passed to esbuild anyways.
45 compact: false,
46 minified: false,
47 },
48 }),
49 injectPreload({
50 files: [
51 {
52 match:
53 /(?:jetbrains-mono-latin-variable-wghtOnly-(?:italic|normal)|roboto-latin-(400|500)-normal).+\.woff2/,
54 attributes: {
55 type: 'font/woff2',
56 as: 'font',
57 crossorigin: 'anonymous',
58 },
59 },
60 ],
61 }),
62 lezer(),
63 ],
64 base: '',
65 define: {
66 __DEV__: JSON.stringify(isDevelopment), // For MobX
67 },
68 build: {
69 assetsDir: '.',
70 outDir: path.join('build/vite', mode),
71 emptyOutDir: true,
72 sourcemap: isDevelopment,
73 minify: !isDevelopment,
74 },
75 server: {
76 host: listenHost,
77 port: listenPort,
78 strictPort: true,
79 proxy: {
80 '/xtext-service': {
81 target: `${apiSecure ? 'https' : 'http'}://${apiHost}:${apiPort}`,
82 ws: true,
83 secure: apiSecure,
84 },
85 },
86 hmr: {
87 host: publicHost,
88 clientPort: publicPort,
89 path: '/vite',
90 },
91 },
92});
diff --git a/subprojects/frontend/webpack.config.js b/subprojects/frontend/webpack.config.js
deleted file mode 100644
index bacb7e4a..00000000
--- a/subprojects/frontend/webpack.config.js
+++ /dev/null
@@ -1,164 +0,0 @@
1const fs = require('fs');
2const path = require('path');
3
4const { DefinePlugin } = require('webpack');
5const HtmlWebpackPlugin = require('html-webpack-plugin');
6const HtmlWebpackInjectPreload = require('@principalstudio/html-webpack-inject-preload');
7const MiniCssExtractPlugin = require('mini-css-extract-plugin');
8const { SubresourceIntegrityPlugin } = require('webpack-subresource-integrity');
9
10const packageInfo = require('./package.json');
11
12const currentNodeEnv = process.env.NODE_ENV || 'development';
13const devMode = currentNodeEnv !== 'production';
14const outputPath = path.resolve(__dirname, 'build/webpack', currentNodeEnv);
15
16function portNumberOrElse (envName, fallback) {
17 const value = process.env[envName];
18 return value ? parseInt(value) : fallback;
19}
20
21const listenHost = process.env['LISTEN_HOST'] || 'localhost';
22const listenPort = portNumberOrElse('LISTEN_PORT', 1313);
23const apiHost = process.env['API_HOST'] || listenHost;
24const apiPort = portNumberOrElse('API_PORT', 1312);
25const publicHost = process.env['PUBLIC_HOST'] || listenHost;
26const publicPort = portNumberOrElse('PUBLIC_PORT', listenPort);
27
28module.exports = {
29 mode: devMode ? 'development' : 'production',
30 entry: './src/index',
31 output: {
32 path: outputPath,
33 publicPath: '/',
34 filename: devMode ? '[name].js' : '[name].[contenthash].js',
35 assetModuleFilename: devMode ? '[name][ext]' : '[name].[contenthash][ext]',
36 clean: true,
37 crossOriginLoading: 'anonymous',
38 },
39 module: {
40 rules: [
41 {
42 test: /.[jt]sx?$/i,
43 include: [path.resolve(__dirname, 'src')],
44 use: [
45 {
46 loader: 'babel-loader',
47 options: {
48 presets: [
49 [
50 '@babel/preset-env',
51 {
52 targets: 'defaults',
53 },
54 ],
55 '@babel/preset-react',
56 [
57 '@babel/preset-typescript',
58 {
59 isTSX: true,
60 allExtensions: true,
61 allowDeclareFields: true,
62 onlyRemoveTypeImports: true,
63 optimizeConstEnums: true,
64 },
65 ]
66 ],
67 plugins: [
68 '@babel/plugin-transform-runtime',
69 ],
70 },
71 },
72 ],
73 },
74 {
75 test: /\.scss$/i,
76 use: [
77 devMode ? 'style-loader' : MiniCssExtractPlugin.loader,
78 'css-loader',
79 {
80 loader: 'sass-loader',
81 options: {
82 implementation: require.resolve('sass'),
83 },
84 },
85 ],
86 },
87 {
88 test: /\.(gif|png|jpe?g|svg?)$/i,
89 use: [
90 {
91 loader: 'image-webpack-loader',
92 options: {
93 disable: true,
94 }
95 },
96 ],
97 type: 'asset',
98 },
99 {
100 test: /\.woff2?$/i,
101 type: 'asset/resource',
102 },
103 ],
104 },
105 resolve: {
106 extensions: ['.ts', '.tsx', '.js', '.jsx'],
107 },
108 devtool: devMode ? 'inline-source-map' : 'source-map',
109 optimization: {
110 providedExports: !devMode,
111 sideEffects: devMode ? 'flag' : true,
112 splitChunks: {
113 chunks: 'all',
114 },
115 },
116 devServer: {
117 client: {
118 logging: 'info',
119 overlay: true,
120 progress: true,
121 webSocketURL: {
122 hostname: publicHost,
123 port: publicPort,
124 protocol: publicPort === 443 ? 'wss' : 'ws',
125 },
126 },
127 compress: true,
128 host: listenHost,
129 port: listenPort,
130 proxy: {
131 '/xtext-service': {
132 target: `${apiPort === 443 ? 'https' : 'http'}://${apiHost}:${apiPort}`,
133 ws: true,
134 },
135 },
136 },
137 plugins: [
138 new DefinePlugin({
139 'DEBUG': JSON.stringify(devMode),
140 'PACKAGE_NAME': JSON.stringify(packageInfo.name),
141 'PACKAGE_VERSION': JSON.stringify(packageInfo.version),
142 }),
143 new MiniCssExtractPlugin({
144 filename: '[name].[contenthash].css',
145 chunkFilename: '[name].[contenthash].css',
146 }),
147 new SubresourceIntegrityPlugin(),
148 new HtmlWebpackPlugin({
149 template: 'src/index.html',
150 }),
151 new HtmlWebpackInjectPreload({
152 files: [
153 {
154 match: /(roboto-latin-(400|500)-normal|jetbrains-mono-latin-variable).*\.woff2/,
155 attributes: {
156 as: 'font',
157 type: 'font/woff2',
158 crossorigin: 'anonymous',
159 },
160 },
161 ],
162 }),
163 ],
164};
diff --git a/subprojects/language-web/build.gradle b/subprojects/language-web/build.gradle
index 6cbacc7b..9c1d93f3 100644
--- a/subprojects/language-web/build.gradle
+++ b/subprojects/language-web/build.gradle
@@ -8,7 +8,7 @@ configurations {
8 canBeConsumed = false 8 canBeConsumed = false
9 canBeResolved = true 9 canBeResolved = true
10 } 10 }
11 11
12 all { 12 all {
13 // Use log4j-over-slf4j instead of log4j 1.x 13 // Use log4j-over-slf4j instead of log4j 1.x
14 exclude group: 'log4j', module: 'log4j' 14 exclude group: 'log4j', module: 'log4j'
@@ -60,7 +60,7 @@ tasks.named('shadowJar') {
60 } 60 }
61} 61}
62 62
63def jettyRun = tasks.register('jettyRun', JavaExec) { 63tasks.register('serveBackend', JavaExec) {
64 dependsOn project.configurations.webapp 64 dependsOn project.configurations.webapp
65 dependsOn sourceSets.main.runtimeClasspath 65 dependsOn sourceSets.main.runtimeClasspath
66 classpath = sourceSets.main.runtimeClasspath 66 classpath = sourceSets.main.runtimeClasspath