From 4156e444c3dec1cc8fcc2df513009242b3b08eb9 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Sat, 3 Oct 2026 22:58:53 -0700 Subject: [PATCH 01/32] web-console: bump http-cache-semantics to 4.3.0 Fixes GHSA-ch52-4w7c-c8xp via npm audit fix. Co-Authored-By: Claude Opus 5.5 --- web-console/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web-console/package-lock.json b/web-console/package-lock.json index 7653851fb42c..c4ce9989e93f 100644 --- a/web-console/package-lock.json +++ b/web-console/package-lock.json @@ -13485,9 +13485,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "dev": true, "license": "BSD-2-Clause" }, From 4c3345a2a082579e5a388e7d49601cf516da310e Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Sat, 3 Oct 2026 23:05:53 -0700 Subject: [PATCH 02/32] web-console, website: migrate from Volta to mise for Node version management - Add .node-version (24.21.0 for web-console, 22 for website) and mise.toml - Remove the volta block from package.json - Exclude .node-version from the RAT license check - Document mise setup in both READMEs Co-Authored-By: Claude Opus 5.5 --- pom.xml | 1 + web-console/.node-version | 1 + web-console/README.md | 7 ++++--- web-console/mise.toml | 21 +++++++++++++++++++++ web-console/package.json | 4 ---- website/.node-version | 1 + website/README.md | 4 +++- website/mise.toml | 21 +++++++++++++++++++++ website/package.json | 4 ---- 9 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 web-console/.node-version create mode 100644 web-console/mise.toml create mode 100644 website/.node-version create mode 100644 website/mise.toml diff --git a/pom.xml b/pom.xml index 46cd05e6cd06..3a2c3318e5ad 100644 --- a/pom.xml +++ b/pom.xml @@ -2419,6 +2419,7 @@ .asf.yaml **/dependency-reduced-pom.xml **/.editorconfig + **/.node-version **/node_modules/** **/public/** **/hadoop.indexer.libs.version diff --git a/web-console/.node-version b/web-console/.node-version new file mode 100644 index 000000000000..df6ae3370f9f --- /dev/null +++ b/web-console/.node-version @@ -0,0 +1 @@ +24.21.0 diff --git a/web-console/README.md b/web-console/README.md index 94fba2c16304..5b5e9321734d 100644 --- a/web-console/README.md +++ b/web-console/README.md @@ -26,9 +26,10 @@ This is the Druid web console that serves as a data management interface for Dru ### Getting started 1. You need to be within the `web-console` directory -2. Install the modules with `npm install` -3. Run `npm run compile` to compile the SCSS files (this usually needs to be done only once) -4. Run `npm start` to start in development mode and proxy Druid requests to `localhost:8888` +2. Install [mise](https://mise.jdx.dev/getting-started.html) and run `mise trust` and `mise install` to get the correct Node.js version (pinned in `.node-version`) +3. Install the modules with `npm install` +4. Run `npm run compile` to compile the SCSS files (this usually needs to be done only once) +5. Run `npm start` to start in development mode and proxy Druid requests to `localhost:8888` **Note:** you can provide an environment variable to proxy to a different Druid host like so: `druid_host=1.2.3.4:8888 npm start` **Note:** you can provide an environment variable to use webpack-bundle-analyzer as a plugin in the build script like so: `BUNDLE_ANALYZER_PLUGIN='TRUE' npm start` diff --git a/web-console/mise.toml b/web-console/mise.toml new file mode 100644 index 000000000000..1ab888d1cfb0 --- /dev/null +++ b/web-console/mise.toml @@ -0,0 +1,21 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +[settings] +idiomatic_version_file_enable_tools = ["node"] diff --git a/web-console/package.json b/web-console/package.json index 08706f1365f5..2c9387592b7d 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -39,10 +39,6 @@ "engines": { "node": ">=24" }, - "volta": { - "node": "24.21.0", - "npm": "11.19.0" - }, "browserslist": [ "> 1% and supports es6", "last 3 versions and supports es6", diff --git a/website/.node-version b/website/.node-version new file mode 100644 index 000000000000..2bd5a0a98a36 --- /dev/null +++ b/website/.node-version @@ -0,0 +1 @@ +22 diff --git a/website/README.md b/website/README.md index 801081f1f466..57a7b17ad3d0 100644 --- a/website/README.md +++ b/website/README.md @@ -36,7 +36,9 @@ NPM is also supported, but Docusaurus recommends `yarn`. ## Dependencies -* [NodeJS](https://nodejs.org/en/download/). Use the version Docusaurus specifies, not a +* [mise](https://mise.jdx.dev/getting-started.html). Run `mise trust` and `mise install` +in the `website` directory to get the correct Node.js version (pinned in `.node-version`). +* [NodeJS](https://nodejs.org/en/download/), if you are not using mise. Use the version Docusaurus specifies, not a newer one. (For example, if 12.x is requested, don't install 16.x.) Docusaurus may require a version newer than that available in your Linux package repository, but older than the diff --git a/website/mise.toml b/website/mise.toml new file mode 100644 index 000000000000..1ab888d1cfb0 --- /dev/null +++ b/website/mise.toml @@ -0,0 +1,21 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +[settings] +idiomatic_version_file_enable_tools = ["node"] diff --git a/website/package.json b/website/package.json index dc5ccac3f2de..b06c8c1aa078 100644 --- a/website/package.json +++ b/website/package.json @@ -14,10 +14,6 @@ "engines": { "node": ">=22.0" }, - "volta": { - "node": ">=22.0", - "npm": "10.8.2" - }, "devDependencies": { "@docusaurus/module-type-aliases": "3.10.1", "@docusaurus/types": "3.10.1", From 47746d5f8ac8e38a1743aa9f7a7a675e0729f66d Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Mon, 5 Oct 2026 15:35:08 -0700 Subject: [PATCH 03/32] change from Ace to CodeMirror --- licenses.yaml | 169 +++++-- licenses/bin/ace-builds.BSD3 | 24 - licenses/bin/fast-equals.MIT | 21 - licenses/bin/react-ace.MIT | 22 - web-console/package-lock.json | 164 +++++-- web-console/package.json | 9 +- web-console/src/ace-modes/ace-mode-helpers.ts | 45 -- web-console/src/ace-modes/ace-modes.spec.ts | 75 --- web-console/src/ace-modes/dsql.ts | 136 ------ web-console/src/ace-modes/hjson.ts | 260 ----------- web-console/src/bootstrap/ace.scss | 130 ------ .../__snapshots__/auto-form.spec.tsx.snap | 11 +- .../src/components/code-editor/MIGRATION.md | 196 ++++++++ .../src/components/code-editor/README.md | 245 ++++++++++ .../code-editor/code-editor-theme.ts | 209 +++++++++ .../code-editor/code-editor.scss} | 15 +- .../components/code-editor/code-editor.tsx | 432 ++++++++++++++++++ web-console/src/components/index.ts | 1 + .../__snapshots__/json-input.spec.tsx.snap | 22 +- .../src/components/json-input/json-input.tsx | 139 +++--- .../show-json-or-stages.tsx | 12 +- .../src/components/show-json/show-json.tsx | 12 +- web-console/src/console-application.tsx | 4 +- .../edit-context-dialog.spec.tsx.snap | 11 +- .../lookup-edit-dialog.spec.tsx.snap | 11 +- .../show-value-dialog/show-value-dialog.scss | 2 +- .../show-value-dialog/show-value-dialog.tsx | 9 +- .../__snapshots__/spec-dialog.spec.tsx.snap | 22 +- .../src/dialogs/spec-dialog/spec-dialog.tsx | 21 +- .../__snapshots__/make-doc-html.spec.ts.snap | 0 .../editor-completions/editor-completion.ts | 30 ++ .../hjson-completions.spec.ts | 0 .../hjson-completions.ts | 17 +- .../make-doc-html.spec.ts | 0 .../make-doc-html.ts | 0 .../sql-completions.spec.ts | 0 .../sql-completions.ts | 9 +- web-console/src/editor-modes/dsql.ts | 97 ++++ .../src/editor-modes/editor-modes.spec.ts | 92 ++++ web-console/src/editor-modes/hjson.ts | 87 ++++ web-console/src/editor-modes/rule-parser.ts | 114 +++++ web-console/src/entry.tsx | 2 - web-console/src/setup-tests.ts | 2 - ...r-state-cache.ts => editor-state-cache.ts} | 31 +- .../src/test-utils/snapshot-serializer.ts | 32 +- .../components/sql-input/sql-input.tsx | 92 ++-- .../explain-dialog.spec.tsx.snap | 22 +- .../explain-dialog/explain-dialog.spec.tsx | 1 + .../explain-dialog/explain-dialog.tsx | 11 +- .../flexible-query-input.spec.tsx.snap | 13 +- .../flexible-query-input.scss | 4 +- .../flexible-query-input.tsx | 294 ++++++------ .../workbench-history-dialog.tsx | 10 +- .../views/workbench-view/workbench-view.tsx | 4 +- 54 files changed, 2125 insertions(+), 1268 deletions(-) delete mode 100644 licenses/bin/ace-builds.BSD3 delete mode 100644 licenses/bin/fast-equals.MIT delete mode 100644 licenses/bin/react-ace.MIT delete mode 100644 web-console/src/ace-modes/ace-mode-helpers.ts delete mode 100644 web-console/src/ace-modes/ace-modes.spec.ts delete mode 100644 web-console/src/ace-modes/dsql.ts delete mode 100644 web-console/src/ace-modes/hjson.ts delete mode 100644 web-console/src/bootstrap/ace.scss create mode 100644 web-console/src/components/code-editor/MIGRATION.md create mode 100644 web-console/src/components/code-editor/README.md create mode 100644 web-console/src/components/code-editor/code-editor-theme.ts rename web-console/src/{bootstrap/ace.ts => components/code-editor/code-editor.scss} (66%) create mode 100644 web-console/src/components/code-editor/code-editor.tsx rename web-console/src/{ace-completions => editor-completions}/__snapshots__/make-doc-html.spec.ts.snap (100%) create mode 100644 web-console/src/editor-completions/editor-completion.ts rename web-console/src/{ace-completions => editor-completions}/hjson-completions.spec.ts (100%) rename web-console/src/{ace-completions => editor-completions}/hjson-completions.ts (89%) rename web-console/src/{ace-completions => editor-completions}/make-doc-html.spec.ts (100%) rename web-console/src/{ace-completions => editor-completions}/make-doc-html.ts (100%) rename web-console/src/{ace-completions => editor-completions}/sql-completions.spec.ts (100%) rename web-console/src/{ace-completions => editor-completions}/sql-completions.ts (97%) create mode 100644 web-console/src/editor-modes/dsql.ts create mode 100644 web-console/src/editor-modes/editor-modes.spec.ts create mode 100644 web-console/src/editor-modes/hjson.ts create mode 100644 web-console/src/editor-modes/rule-parser.ts rename web-console/src/singletons/{ace-editor-state-cache.ts => editor-state-cache.ts} (56%) diff --git a/licenses.yaml b/licenses.yaml index d378875dab1f..2f212633a357 100644 --- a/licenses.yaml +++ b/licenses.yaml @@ -5410,6 +5410,66 @@ version: 6.3.6 --- +name: "@codemirror/autocomplete" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 6.20.3 +license_file_path: licenses/bin/@codemirror-autocomplete.MIT + +--- + +name: "@codemirror/commands" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 6.11.1 +license_file_path: licenses/bin/@codemirror-commands.MIT + +--- + +name: "@codemirror/language" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 6.12.4 +license_file_path: licenses/bin/@codemirror-language.MIT + +--- + +name: "@codemirror/search" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 6.7.2 +license_file_path: licenses/bin/@codemirror-search.MIT + +--- + +name: "@codemirror/state" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 6.7.6 +license_file_path: licenses/bin/@codemirror-state.MIT + +--- + +name: "@codemirror/view" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 6.43.13 +license_file_path: licenses/bin/@codemirror-view.MIT + +--- + name: "@emotion/babel-plugin" license_category: binary module: web-console @@ -5649,6 +5709,46 @@ license_file_path: licenses/bin/@jridgewell-trace-mapping.MIT --- +name: "@lezer/common" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 1.5.3 +license_file_path: licenses/bin/@lezer-common.MIT + +--- + +name: "@lezer/highlight" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 1.2.5 +license_file_path: licenses/bin/@lezer-highlight.MIT + +--- + +name: "@lezer/lr" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 1.4.10 +license_file_path: licenses/bin/@lezer-lr.MIT + +--- + +name: "@marijn/find-cluster-break" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 1.0.4 +license_file_path: licenses/bin/@marijn-find-cluster-break.MIT + +--- + name: "@popperjs/core" license_category: binary module: web-console @@ -5728,16 +5828,6 @@ license_file_path: licenses/bin/@types-react.MIT --- -name: "ace-builds" -license_category: binary -module: web-console -license_name: BSD-3-Clause License -copyright: Ajax.org B.V. -version: 1.44.0 -license_file_path: licenses/bin/ace-builds.BSD3 - ---- - name: "agent-base" license_category: binary module: web-console @@ -5907,6 +5997,16 @@ license_file_path: licenses/bin/cosmiconfig.MIT --- +name: "crelt" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 1.0.7 +license_file_path: licenses/bin/crelt.MIT + +--- + name: "csstype" license_category: binary module: web-console @@ -6087,15 +6187,6 @@ license_file_path: licenses/bin/delayed-stream.MIT --- -name: "diff-match-patch" -license_category: binary -module: web-console -license_name: Apache License version 2.0 -copyright: Google -version: 1.0.5 - ---- - name: "diff" license_category: binary module: web-console @@ -6214,16 +6305,6 @@ license_file_path: licenses/bin/escape-string-regexp.MIT --- -name: "fast-equals" -license_category: binary -module: web-console -license_name: MIT License -copyright: Tony Quetano -version: 5.4.3 -license_file_path: licenses/bin/fast-equals.MIT - ---- - name: "file-saver" license_category: binary module: web-console @@ -6723,16 +6804,6 @@ license_file_path: licenses/bin/proxy-from-env.MIT --- -name: "react-ace" -license_category: binary -module: web-console -license_name: MIT License -copyright: James Hrisho -version: 15.0.0 -license_file_path: licenses/bin/react-ace.MIT - ---- - name: "react-day-picker" license_category: binary module: web-console @@ -6903,6 +6974,16 @@ license_file_path: licenses/bin/source-map.BSD3 --- +name: "style-mod" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 4.1.4 +license_file_path: licenses/bin/style-mod.MIT + +--- + name: "stylis" license_category: binary module: web-console @@ -6983,6 +7064,16 @@ license_file_path: licenses/bin/uuid.MIT --- +name: "w3c-keyname" +license_category: binary +module: web-console +license_name: MIT License +copyright: Marijn Haverbeke +version: 2.2.8 +license_file_path: licenses/bin/w3c-keyname.MIT + +--- + name: "warning" license_category: binary module: web-console diff --git a/licenses/bin/ace-builds.BSD3 b/licenses/bin/ace-builds.BSD3 deleted file mode 100644 index 4760be2a6220..000000000000 --- a/licenses/bin/ace-builds.BSD3 +++ /dev/null @@ -1,24 +0,0 @@ -Copyright (c) 2010, Ajax.org B.V. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - * Neither the name of Ajax.org B.V. nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/bin/fast-equals.MIT b/licenses/bin/fast-equals.MIT deleted file mode 100644 index a90404fced8f..000000000000 --- a/licenses/bin/fast-equals.MIT +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Tony Quetano - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/licenses/bin/react-ace.MIT b/licenses/bin/react-ace.MIT deleted file mode 100644 index 3010ee6d9234..000000000000 --- a/licenses/bin/react-ace.MIT +++ /dev/null @@ -1,22 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014 James Hrisho - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - diff --git a/web-console/package-lock.json b/web-console/package-lock.json index c4ce9989e93f..3417a770db1c 100644 --- a/web-console/package-lock.json +++ b/web-console/package-lock.json @@ -13,11 +13,17 @@ "@blueprintjs/datetime": "^6.2.6", "@blueprintjs/icons": "^6.14.1", "@blueprintjs/select": "^6.3.6", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.1", + "@codemirror/language": "^6.12.4", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", "@flatten-js/interval-tree": "^2.0.3", "@fontsource/open-sans": "^5.0.30", "@internationalized/date": "^3.5.6", + "@lezer/highlight": "^1.2.5", "@tanstack/react-table": "^9.2.4", - "ace-builds": "~1.44.0", "axios": "^1.18.0", "chronoshift": "^1.2.3", "classnames": "^2.2.6", @@ -41,7 +47,6 @@ "memoize-one": "^6.0.0", "numeral": "^2.0.6", "react": "^19.3.0", - "react-ace": "^15.0.0", "react-diff-viewer-continued": "~4.0.6", "react-dom": "^19.3.0", "tslib": "^2.8.0", @@ -879,6 +884,76 @@ "@keyv/serialize": "^1.1.1" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/cascade-layer-name-parser": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-3.0.1.tgz", @@ -6249,6 +6324,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@lizenz/checker": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/@lizenz/checker/-/checker-0.0.2.tgz", @@ -6288,6 +6387,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", @@ -9304,12 +9409,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ace-builds": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.44.0.tgz", - "integrity": "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==", - "license": "BSD-3-Clause" - }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -10791,6 +10890,12 @@ } } }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -11433,12 +11538,6 @@ "node": ">=8" } }, - "node_modules/diff-match-patch": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", - "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", - "license": "Apache-2.0" - }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -12555,15 +12654,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-equals": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.3.tgz", - "integrity": "sha512-8unmvA0qrfpFWBYUate9BTGtQdKHWBYt+KdAIkag7Vvxe7wz8Q2nJg7fLMC0bJqe0bjP31kca7Ckaawrs4Istg==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -19012,22 +19102,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-ace": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz", - "integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==", - "license": "MIT", - "dependencies": { - "ace-builds": "^1.36.3", - "diff-match-patch": "^1.0.5", - "fast-equals": "^5.3.3", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-day-picker": { "version": "8.10.2", "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz", @@ -20560,6 +20634,12 @@ "webpack": "^5.27.0" } }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, "node_modules/stylelint": { "version": "16.26.1", "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz", @@ -21831,6 +21911,12 @@ "node": ">= 0.8" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/web-console/package.json b/web-console/package.json index 2c9387592b7d..6eb77cbc8a42 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -51,11 +51,17 @@ "@blueprintjs/datetime": "^6.2.6", "@blueprintjs/icons": "^6.14.1", "@blueprintjs/select": "^6.3.6", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.1", + "@codemirror/language": "^6.12.4", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", "@flatten-js/interval-tree": "^2.0.3", "@fontsource/open-sans": "^5.0.30", "@internationalized/date": "^3.5.6", + "@lezer/highlight": "^1.2.5", "@tanstack/react-table": "^9.2.4", - "ace-builds": "~1.44.0", "axios": "^1.18.0", "chronoshift": "^1.2.3", "classnames": "^2.2.6", @@ -79,7 +85,6 @@ "memoize-one": "^6.0.0", "numeral": "^2.0.6", "react": "^19.3.0", - "react-ace": "^15.0.0", "react-diff-viewer-continued": "~4.0.6", "react-dom": "^19.3.0", "tslib": "^2.8.0", diff --git a/web-console/src/ace-modes/ace-mode-helpers.ts b/web-console/src/ace-modes/ace-mode-helpers.ts deleted file mode 100644 index 966393177c08..000000000000 --- a/web-console/src/ace-modes/ace-mode-helpers.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import ace from 'ace-builds'; - -// Ace does not ship typings for the classes that modes are built from -export const TextMode: any = ace.require('ace/mode/text').Mode; -export const TextHighlightRules: any = ace.require( - 'ace/mode/text_highlight_rules', -).TextHighlightRules; - -const defineModule: (name: string, deps: string[], payload: object) => void = (ace as any).define; -const modeCache: Record = (ace.config as any).$modes; - -/** - * Registers a mode so that editors can refer to it by name, as in `mode="dsql"`. The mode is only registered once - * (Ace ignores later definitions of a module) so a mode that needs to change should read its configuration when it is - * constructed and be reset with `resetAceMode`. - */ -export function registerAceMode(name: string, Mode: new () => unknown): void { - defineModule(`ace/mode/${name}`, [], { Mode }); -} - -/** - * Ace creates a single instance of each mode and shares it between all editors, forget it so that editors created from - * now on get a new instance. - */ -export function resetAceMode(name: string): void { - delete modeCache[`ace/mode/${name}`]; -} diff --git a/web-console/src/ace-modes/ace-modes.spec.ts b/web-console/src/ace-modes/ace-modes.spec.ts deleted file mode 100644 index 7abde9a68d6f..000000000000 --- a/web-console/src/ace-modes/ace-modes.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import ace from 'ace-builds'; - -import { initAceDsqlMode } from './dsql'; - -function tokenize(mode: string, text: string): [type: string, value: string][][] { - const session = ace.createEditSession(text); - session.setMode(`ace/mode/${mode}`); - const lines: [string, string][][] = []; - for (let row = 0; row < session.getLength(); row++) { - lines.push(session.getTokens(row).map(({ type, value }) => [type, value])); - } - return lines; -} - -function tokenOf(mode: string, text: string, value: string): string | undefined { - return tokenize(mode, text) - .flat() - .find(t => t[1] === value)?.[0]; -} - -describe('ace modes', () => { - it('highlights DruidSQL', () => { - const sql = `--:ISSUE: bad -SELECT COUNT(*), "col", CAST(x AS VARCHAR), TRUE FROM t WHERE y <> 'lit' AND z = 3.5 -- comment`; - expect(tokenOf('dsql', sql, '--:ISSUE: bad')).toEqual('comment.issue'); - expect(tokenOf('dsql', sql, 'SELECT')).toEqual('keyword'); - expect(tokenOf('dsql', sql, 'COUNT')).toEqual('support.function'); - expect(tokenOf('dsql', sql, '"col"')).toEqual('variable.column'); - expect(tokenOf('dsql', sql, 'VARCHAR')).toEqual('storage.type'); - expect(tokenOf('dsql', sql, 'TRUE')).toEqual('constant.language'); - expect(tokenOf('dsql', sql, "'lit'")).toEqual('string'); - expect(tokenOf('dsql', sql, '3.5')).toEqual('constant.numeric'); - expect(tokenOf('dsql', sql, '-- comment')).toEqual('comment'); - }); - - it('highlights the available functions in editors created later', () => { - expect(tokenOf('dsql', 'SELECT MY_FN(x)', 'MY_FN')).toEqual('identifier'); - initAceDsqlMode(new Map([['MY_FN', { args: ['x'], isAggregate: false }]])); - expect(tokenOf('dsql', 'SELECT MY_FN(x)', 'MY_FN')).toEqual('support.function'); - initAceDsqlMode(undefined); - }); - - it('highlights Hjson', () => { - const hjson = `{ - # comment - key: "value" - n: -1.5e3 - flag: true - unquoted: some text -}`; - expect(tokenOf('hjson', hjson, ' comment')).toEqual('comment.line'); - expect(tokenOf('hjson', hjson, 'key')).toEqual('keyword'); - expect(tokenOf('hjson', hjson, '-1.5e3')).toEqual('constant.numeric'); - expect(tokenOf('hjson', hjson, 'true')).toEqual('constant'); - expect(tokenOf('hjson', hjson, 'some text')).toEqual('string'); - }); -}); diff --git a/web-console/src/ace-modes/dsql.ts b/web-console/src/ace-modes/dsql.ts deleted file mode 100644 index 03c25cf30080..000000000000 --- a/web-console/src/ace-modes/dsql.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// This file a modified version of the file located at -// https://github.com/thlorenz/brace/blob/master/mode/sql.js -// Originally licensed under the MIT license (https://github.com/thlorenz/brace/blob/master/LICENSE) -// This file was modified to make the list of keywords more closely adhere to what is found in DruidSQL - -import { dedupe } from 'druid-query-toolkit'; - -import { SQL_CONSTANTS, SQL_DYNAMICS, SQL_KEYWORDS } from '../../lib/keywords'; -import { SQL_DATA_TYPES, SQL_FUNCTIONS } from '../../lib/sql-docs'; -import type { AvailableFunctions } from '../helpers'; - -import { registerAceMode, resetAceMode, TextHighlightRules, TextMode } from './ace-mode-helpers'; - -let availableSqlFunctions: AvailableFunctions | undefined; - -function createDsqlHighlightRules() { - const rules = new TextHighlightRules(); - - // Stuff like: 'with|select|from|where|and|or|group|by|order|limit|having|as|case|' - const keywords = SQL_KEYWORDS.join('|').replace(/\s/g, '|'); - - // Stuff like: 'true|false' - const builtinConstants = SQL_CONSTANTS.join('|'); - - // Stuff like: 'avg|count|first|last|max|min' - const builtinFunctions = dedupe([ - ...SQL_DYNAMICS, - ...Array.from(SQL_FUNCTIONS.keys()), - ...(availableSqlFunctions?.keys() || []), - ]).join('|'); - - // Stuff like: 'int|numeric|decimal|date|varchar|char|bigint|float|double|bit|binary|text|set|timestamp' - const dataTypes = Array.from(SQL_DATA_TYPES.keys()).join('|'); - - const keywordMapper = rules.createKeywordMapper( - { - 'support.function': builtinFunctions, - 'keyword': keywords, - 'constant.language': builtinConstants, - 'storage.type': dataTypes, - }, - 'identifier', - true, - ); - - rules.$rules = { - start: [ - { - token: 'comment.issue', - regex: '--:ISSUE:.*$', - }, - { - token: 'comment', - regex: '--.*$', - }, - { - token: 'comment', - start: '/\\*', - end: '\\*/', - }, - { - token: 'variable.column', // " quoted reference - regex: '".*?"', - }, - { - token: 'string', // ' string literal - regex: "'.*?'", - }, - { - token: 'constant.numeric', // float - regex: '[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b', - }, - { - token: keywordMapper, - regex: '[a-zA-Z_$][a-zA-Z0-9_$]*\\b', - }, - { - token: 'keyword.operator', - regex: '\\+|\\-|\\/|\\/\\/|%|<@>|@>|<@|&|\\^|~|<|>|<=|=>|==|!=|<>|=', - }, - { - token: 'paren.lparen', - regex: '[\\(]', - }, - { - token: 'paren.rparen', - regex: '[\\)]', - }, - { - token: 'text', - regex: '\\s+', - }, - ], - }; - rules.normalizeRules(); - return rules; -} - -class DsqlMode extends TextMode { - $highlightRules = createDsqlHighlightRules(); - $behaviour = this.$defaultBehaviour; - $id = 'ace/mode/dsql'; - lineCommentStart = '--'; - - getCompletions() { - return []; - } -} - -registerAceMode('dsql', DsqlMode); - -/** - * Highlights the functions that the cluster has (in addition to the documented ones) in editors created after this call - */ -export function initAceDsqlMode(sqlFunctions: AvailableFunctions | undefined) { - availableSqlFunctions = sqlFunctions; - resetAceMode('dsql'); -} diff --git a/web-console/src/ace-modes/hjson.ts b/web-console/src/ace-modes/hjson.ts deleted file mode 100644 index e7073a3bdb23..000000000000 --- a/web-console/src/ace-modes/hjson.ts +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// This file a modified version of the file located at -// https://github.com/thlorenz/brace/blob/master/mode/hjson.js -// Originally licensed under the MIT license (https://github.com/thlorenz/brace/blob/master/LICENSE) -// This file was modified to remove the folding functionality that did not play nice when loaded along side the -// sql mode (which does not have any folding function) - -import { registerAceMode, TextHighlightRules, TextMode } from './ace-mode-helpers'; - -function createHjsonHighlightRules() { - const rules = new TextHighlightRules(); - - rules.$rules = { - 'start': [ - { - include: '#comments', - }, - { - include: '#rootObject', - }, - { - include: '#value', - }, - ], - '#array': [ - { - token: 'paren.lparen', - regex: /\[/, - push: [ - { - token: 'paren.rparen', - regex: /\]/, - next: 'pop', - }, - { - include: '#value', - }, - { - include: '#comments', - }, - { - token: 'text', - regex: /,|$/, - }, - { - token: 'invalid.illegal', - regex: /[^\s\]]/, - }, - { - defaultToken: 'array', - }, - ], - }, - ], - '#comments': [ - { - token: ['comment.punctuation', 'comment.line'], - regex: /(#)(.*$)/, - }, - { - token: 'comment.punctuation', - regex: /\/\*/, - push: [ - { - token: 'comment.punctuation', - regex: /\*\//, - next: 'pop', - }, - { - defaultToken: 'comment.block', - }, - ], - }, - { - token: ['comment.punctuation', 'comment.line'], - regex: /(\/\/)(.*$)/, - }, - ], - '#constant': [ - { - token: 'constant', - regex: /\b(?:true|false|null)\b/, - }, - ], - '#keyname': [ - { - token: 'keyword', - regex: /(?:[^,{[}\]\s]+|"(?:[^"\\]|\\.)*")\s*(?=:)/, - }, - ], - '#mstring': [ - { - token: 'string', - regex: /'''/, - push: [ - { - token: 'string', - regex: /'''/, - next: 'pop', - }, - { - defaultToken: 'string', - }, - ], - }, - ], - '#number': [ - { - token: 'constant.numeric', - regex: /-?(?:0|[1-9]\d*)(?:(?:\.\d+)?(?:[eE][+-]?\d+)?)?/, - comment: 'handles integer and decimal numbers', - }, - ], - '#object': [ - { - token: 'paren.lparen', - regex: /\{/, - push: [ - { - token: 'paren.rparen', - regex: /\}/, - next: 'pop', - }, - { - include: '#keyname', - }, - { - include: '#value', - }, - { - token: 'text', - regex: /:/, - }, - { - token: 'text', - regex: /,/, - }, - { - defaultToken: 'paren', - }, - ], - }, - ], - '#rootObject': [ - { - token: 'paren', - regex: /(?=\s*(?:[^,{[}\]\s]+|"(?:[^"\\]|\\.)*")\s*:)/, - push: [ - { - token: 'paren.rparen', - regex: /---none---/, - next: 'pop', - }, - { - include: '#keyname', - }, - { - include: '#value', - }, - { - token: 'text', - regex: /:/, - }, - { - token: 'text', - regex: /,/, - }, - { - defaultToken: 'paren', - }, - ], - }, - ], - '#string': [ - { - token: 'string', - regex: /"/, - push: [ - { - token: 'string', - regex: /"/, - next: 'pop', - }, - { - token: 'constant.language.escape', - regex: /\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})/, - }, - { - token: 'invalid.illegal', - regex: /\\./, - }, - { - defaultToken: 'string', - }, - ], - }, - ], - '#ustring': [ - { - token: 'string', - regex: /\b[^:,0-9\-{[}\]\s].*$/, - }, - ], - '#value': [ - { - include: '#constant', - }, - { - include: '#number', - }, - { - include: '#string', - }, - { - include: '#array', - }, - { - include: '#object', - }, - { - include: '#comments', - }, - { - include: '#mstring', - }, - { - include: '#ustring', - }, - ], - }; - - rules.normalizeRules(); - return rules; -} - -class HjsonMode extends TextMode { - $highlightRules = createHjsonHighlightRules(); - lineCommentStart = '//'; - blockComment = { start: '/*', end: '*/' }; - $id = 'ace/mode/hjson'; -} - -registerAceMode('hjson', HjsonMode); diff --git a/web-console/src/bootstrap/ace.scss b/web-console/src/bootstrap/ace.scss deleted file mode 100644 index 96d45d64664d..000000000000 --- a/web-console/src/bootstrap/ace.scss +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@use '../variables' as *; -@use '../blueprint-overrides/common/colors' as *; - -// This file contains various overrides for the Ace editor. -// Ideally they should be incorporated into a theme - -.ace-solarized-dark { - background-color: rgba($dark-gray1, 0.5); - - // START: Custom code styles - .ace_variable.ace_column { - color: #2ceefb; - } - - .ace_comment.ace_issue { - color: #cb3116; - text-decoration: underline; - text-decoration-style: wavy; - } - - .ace_storage.ace_type { - color: #27c923; - } - // END: Custom code styles - - &.no-background { - background-color: transparent; - } - - &.placeholder-padding { - .ace_placeholder { - // react-ace renders the placeholder outside of the editor content so it does not get the padding set with - // renderer.setPadding and renderer.setScrollMargin - margin-top: 10px; - } - } - - .ace_gutter { - background: $gray5; - color: $gray1; - - .#{$bp-ns}-dark & { - background: $dark-gray4; - color: $gray5; - } - } - - .ace_gutter-active-line { - background-color: rgba($gray1, 0.6); - } - - &:not(.ace_autocomplete) .ace_text-layer { - filter: brightness(1.5) saturate(0.9); - } - - &.ace_editor.ace_dark.ace_autocomplete { - background: $dark-gray4; - border: none; - border-radius: 2px; - box-shadow: 0 5px 15px rgba(15, 19, 32, 0.45); - - .ace_text-layer { - filter: brightness(1.1); - } - } -} - -// Two classes to win over Ace's `.ace_tooltip.ace_dark` -.ace_tooltip.ace_tooltip { - padding: 10px; - // The doc tooltip is rendered inside the autocomplete popup, do not inherit its code font - font-family: $pt-font-family; - font-size: $pt-font-size; - line-height: normal; - color: #c1ccd5; - width: 500px; - // Ace limits the doc tooltip to 400x300 (scrolling the rest) - max-width: none !important; - max-height: none !important; - display: block; - height: auto; - white-space: initial; - background: $gray2; - border: none; - border-radius: 2px; - box-shadow: 0 5px 15px rgba(15, 19, 32, 0.45); - - .#{$bp-ns}-dark & { - background: $dark-gray4; - } - - & > * { - filter: brightness(1.1); - } - - .doc-name { - font-size: 18px; - border-bottom: 2px solid rgba(193, 204, 213, 0.5); - padding-bottom: 4px; - color: #93ca12; - } - - .doc-syntax { - padding-top: 8px; - padding-bottom: 10px; - } - - .doc-name, - .doc-syntax { - font-family: Monaco, Menlo, 'Ubuntu Mono', Consolas, source-code-pro, monospace; - } -} diff --git a/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap b/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap index 71de572ea031..d909536d71e5 100644 --- a/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap +++ b/web-console/src/components/auto-form/__snapshots__/auto-form.spec.tsx.snap @@ -400,11 +400,14 @@ exports[`AutoForm matches snapshot 1`] = ` class="json-input" >
- +
+ +
diff --git a/web-console/src/components/code-editor/MIGRATION.md b/web-console/src/components/code-editor/MIGRATION.md new file mode 100644 index 000000000000..cf22b1a38700 --- /dev/null +++ b/web-console/src/components/code-editor/MIGRATION.md @@ -0,0 +1,196 @@ + + +# Migration from Ace to CodeMirror 6 + +This document records how the web console moved from the [Ace](https://ace.c9.io/) editor (through `react-ace`) to +[CodeMirror 6](https://codemirror.net/). The goal was a drop-in replacement: same features and as close to the same +look as possible. For how to use the new component, see [README.md](./README.md). + +## Summary + +- `ace-builds` and `react-ace` were removed. `@codemirror/{state,view,language,autocomplete,commands,search}` and + `@lezer/highlight` were added. +- All 10 places that rendered `` now render the new `CodeEditor` component (`src/components/code-editor/`). +- The custom DruidSQL (`dsql`) and Hjson (`hjson`) Ace modes were ported to CodeMirror `StreamLanguage`s. +- The SQL and Hjson completion builders were kept. Only their return type changed from Ace's `ValueCompletion` to a + console-owned `EditorCompletion`. +- The workbench's "run this query" gutter markers and hover highlight were reimplemented as CodeMirror extensions. +- The styling reproduces Ace's `solarized_dark` theme with the console's overrides. It was compared side by side + against master in the browser. + +## What moved where + +| Before (Ace) | After (CodeMirror) | +| ------------------------------------------------ | ------------------------------------------------------------------------ | +| `react-ace`'s `` | `src/components/code-editor/code-editor.tsx` (`CodeEditor`) | +| `src/bootstrap/ace.ts` (imports, theme, modes) | Removed. Everything is imported where it is used | +| `src/bootstrap/ace.scss` (theme overrides) | `src/components/code-editor/code-editor-theme.ts` (+ `code-editor.scss`) | +| `src/ace-modes/dsql.ts`, `hjson.ts` | `src/editor-modes/dsql.ts`, `hjson.ts` | +| `src/ace-modes/ace-mode-helpers.ts` | `src/editor-modes/rule-parser.ts` (Ace-style rules → stream parser) | +| `src/ace-modes/ace-modes.spec.ts` | `src/editor-modes/editor-modes.spec.ts` | +| `initAceDsqlMode(functions)` | `initDsqlMode(functions)` | +| `src/ace-completions/*` | `src/editor-completions/*` (+ `editor-completion.ts` for the type) | +| `Ace.ValueCompletion` | `EditorCompletion` (`value`, `caption`, `score`, `meta`, `docHTML`) | +| `src/singletons/ace-editor-state-cache.ts` | `src/singletons/editor-state-cache.ts` (`EditorStateCache`) | +| `editor.getSelection().moveCursorTo(row, col)` | `focusEditorAt(view, { row, column })` | + +### Call sites + +| Component | Notes | +| ------------------------------- | ----------------------------------------------------------------------------------------- | +| `FlexibleQueryInput` (workbench) | Rewritten. See [Partial query markers](#partial-query-markers) | +| `SqlInput` (explore view) | Same props and behavior | +| `JsonInput` | Same props. The change handler is only passed when `onChange` is given (that makes it read-only) | +| `ShowJson`, `ShowJsonOrStages` | Read-only Hjson | +| `SpecDialog` | `height="500px"` is now explicit (it used to come from react-ace's default) | +| `ShowValueDialog` | Its SCSS targeted `.ace-editor`, a class Ace never set. It now targets `.code-editor` | +| `WorkbenchHistoryDialog` | Read-only, `dsql` or `hjson` | +| `ExplainDialog` | Read-only Hjson | + +## Behavior parity + +These Ace behaviors were deliberately preserved: + +- **Controlled value:** react-ace replaced the text on prop changes without calling `onChange`. `CodeEditor` does the + same, but it applies only the changed part, so the cursor stays where it was and the change is undoable. +- **Read-only:** `readOnly={!onChange}` was the pattern everywhere, so the editor is now read-only exactly when + `onChange` is not given. +- **Autocomplete triggering:** like Ace's live autocompletion, the list opens while typing a word (Ace's identifier + characters, including `$` and `-`) and on Ctrl-Space. Tab accepts a suggestion as well as Enter. +- **Autocomplete inputs:** the old completers worked out `charBeforePrefix`, `lineBeforePrefix` and `textBefore` + from the Ace session. `CodeEditor` now computes these once and passes them in a `CompletionRequest`. +- **Ranking:** Ace's `score` becomes CodeMirror's `boost` (clamped to ±99). CodeMirror ranks by match quality first and + boost second, which gave the same ordering in practice. +- **Doc tooltip:** `docHTML` is shown in a 500px panel next to the list, styled like the old `.ace_tooltip` + (`doc-name`, `doc-syntax` classes). +- **Tab:** with nothing selected, Tab inserts spaces to the next 2-column stop; with a selection it indents the lines. + This matches Ace (CodeMirror does not bind Tab by default). +- **Bracket behavior:** Ace auto-closed brackets in `dsql` (`CstyleBehaviour`) but not in `hjson`. The same applies + now, except that `{` is not auto-closed in SQL. See [Intentional differences](#intentional-differences). +- **Comment toggling:** Cmd/Ctrl-/ uses `--` for `dsql`, and `//` and `/* */` for `hjson`. +- **Undo across tabs:** the old cache kept Ace's `UndoManager` per workbench tab. The new cache keeps + `EditorState.toJSON({ history })` and restores it with `EditorState.fromJSON`, so undo works after switching tabs. +- **Highlighting the cluster's functions:** `initDsqlMode` adds them to the highlighting. With Ace, only editors created + afterwards picked them up. With CodeMirror, existing editors pick them up as soon as they re-highlight (on the next + edit). + +### Syntax highlighting port + +The Ace modes were defined as Ace highlight rules: per state, a list of regexes tried in order, with push/pop of +states. `createRuleParser` runs rules of the same shape as a CodeMirror stream parser, so the rules were ported nearly +verbatim. A few details that keep the results identical: + +- Rules are compiled as **sticky regexes matched against the whole line**, not a slice of it, so `\b` and `$` behave + as they did in Ace. +- Zero-width rules (Hjson's "object without braces" lookahead) can push a state without consuming input, as in Ace. +- When several keyword lists contain the same word, **the last list wins** (for example, a data type that is also a + keyword is highlighted as a type). This matches Ace's `createKeywordMapper`. +- Token names map to Lezer tags through `TOKEN_TABLE`. `--:ISSUE:` comments and double-quoted column references got + custom tags (`editorTags.issue`, `editorTags.column`), as they had custom Ace token classes. + +### Partial query markers + +In `FlexibleQueryInput`, a play button in the gutter runs a single query when the text contains several. + +- **Before:** markers were Ace "breakpoints" (`session.setBreakpoint(row, className)`). The hover highlight was an Ace + text marker (`session.addMarker(new Range(...))`). +- **After:** + - A `StateField` provides `SubQueryGutterMarker`s to the `lineNumberMarkers` facet. Each marker sets + `elementClass = 'sub-query-gutter-marker query-'` on its line-number cell, the same classes Ace used. The + existing SCSS (the blue square and triangle drawn with `:before`/`:after`) works unchanged apart from adding + `position: relative`. + - A second `StateField` holds a `Decoration.mark` with the `sub-query-highlight` class over the hovered query's + offsets. + - Both are updated with `StateEffect`s. The click and hover handlers on the wrapping `div` are unchanged: they + still find the row from the `query-` class. + - The `ResizeSensor` that fed Ace an explicit pixel height is gone, because the editor now fills its container with + CSS. + +## Styling + +The look was matched by comparing screenshots of the workbench on master and on this branch, both taken with +Playwright at 2× scale. + +- **Token colors:** the old theme drew text through `filter: brightness(1.5) saturate(0.9)`. In CodeMirror, the + active-line and highlight backgrounds live inside the content element, so the same filter would brighten them too. + The filter was applied to each solarized color ahead of time instead: + + | Token | Ace class | Solarized | Rendered (used now) | + | ----------------- | ------------------------ | --------- | ------------------- | + | Default text | | `#839496` | `#c7dde0` | + | Keyword | `keyword` | `#859900` | `#c8e315` | + | Function | `support.function` | `#268bd2` | `#45cef7` | + | Constant / escape | `constant.language` | `#b58900` | `#facd14` | + | Data type | `storage.type` (custom) | `#27c923` | `#49f943` | + | Number | `constant.numeric` | `#d33682` | `#f256bc` | + | String | `string` | `#2aa198` | `#4deee1` | + | Comment (italic) | `comment` | `#657b83` | `#9ab8c3` | + | `--:ISSUE:` (wavy underline) | `comment.issue` (custom) | `#cb3116` | `#f04d29` | + | Column reference | `variable.column` (custom) | `#2ceefb` | `#51fbfb` | + +- **Metrics:** 12px Monaco/Menlo stack with `line-height: normal` (≈16px lines, like Ace). The gutter cells are padded + `0 13px 0 21px` to match Ace's gutter width. `padding={10}` reproduces `renderer.setPadding(10)` plus + `setScrollMargin(10, 10)`. +- **Chrome:** background `rgba($dark-gray1, 0.5)` (or transparent), gutter `$dark-gray4` / `$gray5`, active line + `rgba(255, 255, 255, 0.1)`, active gutter cell `rgba($gray1, 0.6)`, 2px `#d30102` cursor, outlined matching bracket. +- **Autocomplete popup:** `$dark-gray4` background with no border, a 2px radius and the console's shadow. It is 300px + wide with 1.4 line height and up to 8 rows. The selected row is `#3a674e`, matched letters `#a2de14`, and the meta + text is right-aligned at 50% opacity. +- **Placeholder:** Arial, scaled to 0.9, italic, `#657b83` at 70% opacity. The old `placeholder-padding` hack is gone, + because the CodeMirror placeholder sits inside the padded content. + +## Intentional differences + +- **The typed word is not suggested.** The SQL completer suggests words found in the text, which includes the + half-typed word itself. Ace gathered suggestions after the first character (too short to count as a reference) and + then only filtered them, so the typed word never appeared. CodeMirror can gather them later, so completions equal to + the prefix are now dropped explicitly. +- **`{` is not auto-closed in SQL.** Ace only "maybe" inserted the closing brace (it added it on Enter). Typing `{` into + the workbench usually starts a native JSON query, and an eager `}` was left behind once the mode switched to Hjson. +- **Undo granularity:** CodeMirror groups typing into undo steps differently from Ace. +- **Tooltips:** all editors share one tooltip container that is *prepended* to ``. Like Ace's popup, it can't be + clipped by the editor's containers. Prepending also keeps `document.body.lastChild` pointing at Blueprint portals, + which about 25 dialog specs snapshot. + +## Known gaps + +- **Placeholder offset:** the placeholder text starts about 6px further left than Ace's did. +- **Long captions:** they are cut off with "…" at the end. Ace shortened them so that the matched part stayed visible. +- **Not checked visually:** the explore-view filter and measure popovers (`SqlInput` inside Blueprint popovers), the + spec dialog, the history dialog and the explain dialog. They use the same component. The popovers deserve a look, + since the autocomplete renders in a body-level container there. + +## Tests + +- `editor-modes.spec.ts` was rewritten to tokenize with the CodeMirror parser. It covers the same cases as before, plus + block comments, arrays and brace-less Hjson objects. +- The snapshot serializer replaces each `.cm-editor` with a comment (mode, value, placeholder, read-only) and drops + CodeMirror's generated theme classes. 10 snapshots were updated; in every case only the wrapper markup changed. +- `explain-dialog.spec.tsx` mocked the entire `hooks` module, which removed hooks that `CodeEditor` uses. The mock now + spreads `jest.requireActual` and overrides only `useQueryManager`. +- Checked in a running console: the gutter markers run the clicked query, undo history survives switching tabs, and + both modes highlight like master. + +## Licensing + +`licenses.yaml` was regenerated with `script/licenses`. The CodeMirror packages and their dependencies (`@lezer/*`, +`style-mod`, `w3c-keyname`, `crelt`, `@marijn/find-cluster-break`, all MIT) were added under `licenses/bin`. The +entries and license files for `ace-builds`, `react-ace` and `fast-equals` (a react-ace dependency) were removed; +`diff-match-patch`, another react-ace dependency, dropped out of `licenses.yaml` too. diff --git a/web-console/src/components/code-editor/README.md b/web-console/src/components/code-editor/README.md new file mode 100644 index 000000000000..e806aab5c7a7 --- /dev/null +++ b/web-console/src/components/code-editor/README.md @@ -0,0 +1,245 @@ + + +# CodeEditor + +`CodeEditor` is the web console's code editor: a React wrapper around [CodeMirror 6](https://codemirror.net/). Use it +whenever the console shows or edits SQL or JSON: the workbench query input, the explore view's SQL inputs, JSON spec +dialogs, read-only JSON views and so on. + +It is a controlled component that you give a string and an `onChange` callback. It comes with the console's look +(syntax colors, gutter, autocomplete popup), DruidSQL and Hjson highlighting and context-aware autocomplete. + +## Files + +| File | What it holds | +| ----------------------- | -------------------------------------------------------------------------------- | +| `code-editor.tsx` | The component, its props, the CodeMirror setup and the `focusEditorAt` helper | +| `code-editor-theme.ts` | The editor theme (layout, gutter, popups) and the syntax highlighting colors | +| `code-editor.scss` | The few styles for the wrapper element that can't live in the CodeMirror theme | + +Related code lives elsewhere: + +| Location | What it holds | +| -------------------------------------- | ----------------------------------------------------------------------- | +| `src/editor-modes/` | The `dsql` and `hjson` languages (syntax highlighting rules) | +| `src/editor-completions/` | What to suggest when autocompleting SQL and Hjson | +| `src/singletons/editor-state-cache.ts` | Keeps editor state (undo history, selection) between mounts | + +## Basic usage + +```tsx +import { CodeEditor } from '../../components'; + +// Editable SQL + + +// Read-only JSON (leaving out onChange makes the editor read-only) + +``` + +## Props + +| Prop | Type | Notes | +| ----------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `value` | `string` | Required. The text to show. | +| `onChange` | `(value: string) => void` | Called when the **user** edits the text. **If omitted, the editor is read-only.** | +| `onBlur` | `() => void` | | +| `mode` | `'dsql' \| 'hjson' \| 'text'` | The language, which controls highlighting, comment toggling and bracket auto-closing. Defaults to `'text'`. | +| `width` / `height` | `string` | CSS sizes for the wrapper. With no `height`, the editor grows with its content. | +| `className` | `string` | Added to the wrapper `div` (which always has the `code-editor` class). | +| `showGutter` | `boolean` | Shows line numbers. | +| `padding` | `number` | Pads the text on all sides (in px). By default there is only a little horizontal padding. | +| `transparentBackground` | `boolean` | Drops the editor's own background. | +| `placeholder` | `string` | Shown while the editor is empty. | +| `autoFocus` | `boolean` | Focuses the editor when it mounts. | +| `getCompletions` | `(request) => EditorCompletion[]` | Turns on autocomplete. See [Autocomplete](#autocomplete). | +| `stateCacheId` | `string` | Remembers the undo history and selection under this id, so they survive the editor being unmounted. | +| `extensions` | `Extension` | Extra CodeMirror extensions. **Only read when the editor is created.** | +| `ref` | `Ref` | Gives you the underlying CodeMirror `EditorView`. | + +## How it works + +### The value is controlled + +The editor keeps its own CodeMirror document, and the component keeps that document in sync with `value`: + +- When the user types, `onChange` is called with the full new text. +- When `value` changes from the outside (formatting a query, inserting a column name), the editor applies **only the + part that changed**, so the cursor stays put and the change can be undone. External changes do **not** call + `onChange`. + +You can therefore treat it like an ``. If the parent rewrites or rejects what was typed, the editor shows what +the parent passes back. + +### Props that change vs. props read once + +Most props (`mode`, `onChange`/read-only, `showGutter`, `placeholder`, `getCompletions`, `onBlur`) can change at any +time. The editor updates in place without being recreated. The workbench input uses this to switch between `dsql` +and `hjson` as soon as the text starts with `{`. + +`padding`, `extensions` and `autoFocus` are only read when the editor is created. Changing `stateCacheId` recreates +the editor. + +### Remembering state between mounts + +When `stateCacheId` is set, the editor saves its state (undo history and selection) when it unmounts. When an editor +with the same id mounts again, it restores that state. The workbench uses this so that undo still works after you +switch tabs and come back. If `value` changed while the editor was away, the restored editor is updated to match. + +Remove the saved state with `EditorStateCache.deleteState(id)` once it is no longer needed (the workbench does this +when a tab is closed). + +## Autocomplete + +Pass `getCompletions` to turn on autocomplete. Suggestions appear as the user types a word, and Ctrl-Space opens the +list on demand. Enter or Tab accepts the selected one. The callback gets a `CompletionRequest` describing where the +cursor is: + +```ts +interface CompletionRequest { + allText: string; // The whole text + prefix: string; // The (partial) word being completed + charBeforePrefix: string; // The character before it: '\n' at the start of a line, '' at the start of the text + textBeforePrefix: string; // Everything before charBeforePrefix + lineBeforePrefix: string; // The part of the current line before charBeforePrefix +} +``` + +It returns `EditorCompletion`s (from `src/editor-completions/editor-completion.ts`): + +```ts +interface EditorCompletion { + value: string; // What gets inserted + caption?: string; // What is shown in the list (defaults to value) + score?: number; // Ranks equally good matches, higher first + meta?: string; // Shown on the right, like 'column' or 'function' + docHTML?: string; // Shown in a panel next to the list when the item is selected +} +``` + +Usually you don't write completions yourself. Hook up the existing builders: + +```tsx +const getCompletions = useCallback( + ({ allText, prefix, charBeforePrefix, lineBeforePrefix }: CompletionRequest) => + getSqlCompletions({ allText, prefix, charBeforePrefix, lineBeforePrefix, columnMetadata, availableSqlFunctions }), + [columnMetadata, availableSqlFunctions], +); + +; +``` + +Things to know: + +- The callback can change between renders, and the latest one is always used. +- The editor filters and ranks your list against the prefix (fuzzy matching), so you don't need to filter it yourself. +- A suggestion that is exactly the typed prefix is hidden, because it adds nothing. +- The popup and its doc panel are rendered in a shared container at the start of ``, so they are never clipped by + dialogs or popovers that contain the editor. + +## Working with the `EditorView` + +For anything beyond the props, use `ref` to get the CodeMirror `EditorView`: + +```tsx +const viewRef = useRef(undefined); + +; + +// Later: put the cursor on row 3, column 5 (both 0-based) and focus the editor +if (viewRef.current) focusEditorAt(viewRef.current, { row: 3, column: 5 }); +``` + +`focusEditorAt` is exported next to the component. It clamps out-of-range positions and scrolls the cursor into view. +It is used to jump to the location of query errors. + +If you need decorations, gutter markers or other custom behavior, pass CodeMirror extensions through `extensions`. +`FlexibleQueryInput` (`src/views/workbench-view/flexible-query-input/`) is the example to read. It adds: + +- a `StateField` that puts a "run this query" marker on the line numbers (via the `lineNumberMarkers` facet), with the + `sub-query-gutter-marker query-` classes on the line-number cell +- a `StateField` with a mark decoration that highlights a query while its marker is hovered + +Both are changed with `StateEffect`s dispatched on the view. Clicks and hovers are handled by plain React handlers on +a wrapping `div`. + +## Languages + +The modes are CodeMirror `StreamLanguage`s defined in `src/editor-modes/`: + +- **`dsql`** (DruidSQL): Keywords, functions, data types and constants come from `lib/keywords.ts` and + `lib/sql-docs`. Double-quoted references (`"column"`) and `--:ISSUE:` comments get their own colors. + `initDsqlMode(availableSqlFunctions)` adds the functions that the cluster reports, and is called once the + capabilities are known. The SQL mode auto-closes `(`, `[`, `'` and `"` (not `{`), and Cmd/Ctrl-/ toggles `--` + comments. +- **`hjson`** (Hjson/JSON): highlights keys, strings, numbers, escapes and `#`, `//` and `/* */` comments, including + objects without the outer braces. + +Both are built with `createRuleParser` (`src/editor-modes/rule-parser.ts`). It takes Ace-style rules: for each state, a +list of regexes tried in order, where a rule can push or pop a state. Token names map to highlighting tags through +`TOKEN_TABLE` in the same file. + +To add a token type, add it to `TOKEN_TABLE`, emit it from a rule, and give its tag a color in +`codeEditorHighlightStyle` (in `code-editor-theme.ts`). + +## Styling + +The theme reproduces the look of the Ace editor the console used before: Ace's `solarized_dark` theme plus the +console's overrides. Most of it is in `code-editor-theme.ts` as an `EditorView.theme(...)`, which CodeMirror scopes to +the editor and to its tooltips. Some things to know before changing it: + +- **The token colors are pre-brightened.** The Ace theme drew text through `filter: brightness(1.5) saturate(0.9)`. + The colors in `codeEditorHighlightStyle` are the result of that filter. Don't add a CSS filter to `.cm-content`, + because it would also brighten the active-line and highlight backgrounds. +- **Console colors are copied as constants.** The theme lives in TypeScript, so the few SCSS colors it needs + (`$dark-gray1`, `$dark-gray4`, `$gray1`, `$gray5`) are copied in with a comment pointing to + `blueprint-overrides/common/_colors.scss`. Keep them in sync if those change. +- **Mind selector specificity.** CodeMirror's base theme uses selectors like `&dark .cm-gutters`. A theme rule wins + when its selector is as specific as the base one. `codeEditorPaddingTheme` uses slightly more specific selectors on + purpose so it beats the main theme. +- **The wrapper** gets `position: relative; overflow: hidden` (in `code-editor.scss`), like Ace's root element, so the + editor never pushes past the size it is given in flex layouts. +- **Style it from the outside** with the wrapper's `className` (for size, borders and layout), or `.cm-*` class names + under it when you need to. + +## Keyboard + +The standard CodeMirror keymaps are on: editing, undo/redo, search (Cmd/Ctrl-F) and autocomplete. Tab behaves like it +did in Ace. With nothing selected it inserts spaces up to the next 2-column tab stop. With a selection it indents the +selected lines, and Shift-Tab un-indents. + +## Testing + +DOM snapshots don't include CodeMirror's internal DOM. The snapshot serializer (`src/test-utils/snapshot-serializer.ts`) +replaces each editor with a comment listing what the console configured, for example: + +```html +
+
+ +
+
+``` + +So a snapshot changes only when the mode, value, placeholder or read-only state changes, not when CodeMirror is +upgraded. + +The language rules are tested directly in `src/editor-modes/editor-modes.spec.ts`. It parses text with the language's +parser and checks the token name produced for each piece of text. diff --git a/web-console/src/components/code-editor/code-editor-theme.ts b/web-console/src/components/code-editor/code-editor-theme.ts new file mode 100644 index 000000000000..b41ccec359e9 --- /dev/null +++ b/web-console/src/components/code-editor/code-editor-theme.ts @@ -0,0 +1,209 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HighlightStyle } from '@codemirror/language'; +import { EditorView } from '@codemirror/view'; +import { tags } from '@lezer/highlight'; + +import { editorTags } from '../../editor-modes/rule-parser'; + +// The editor is styled to look like the Ace editor (with the solarized_dark theme and some overrides) that the console +// used to use. + +const MONOSPACE_FONT = + "Monaco, Menlo, 'Ubuntu Mono', Consolas, 'Source Code Pro', source-code-pro, monospace"; + +// Colors from blueprint-overrides/common/_colors.scss +const DARK_GRAY1 = '24, 28, 45'; +const DARK_GRAY4 = '#383d57'; +const GRAY1 = '96, 101, 128'; +const GRAY5 = '#bdc1d1'; + +const POPUP_SHADOW = '0 5px 15px rgba(15, 19, 32, 0.45)'; + +export const codeEditorTheme = EditorView.theme( + { + '&': { + height: '100%', + fontSize: '12px', + color: '#c7dde0', + backgroundColor: `rgba(${DARK_GRAY1}, 0.5)`, + }, + '.no-background > &': { + backgroundColor: 'transparent', + }, + '&.cm-focused': { + outline: 'none', + }, + '.cm-scroller': { + fontFamily: MONOSPACE_FONT, + lineHeight: 'normal', + }, + '.cm-content': { + padding: '0', + }, + '.cm-line': { + padding: '0 4px', + }, + '.cm-cursor, .cm-dropCursor': { + borderLeft: '2px solid #d30102', + }, + '&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground': + { + background: 'rgba(255, 255, 255, 0.1)', + }, + '.cm-activeLine': { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + '.cm-gutters': { + backgroundColor: DARK_GRAY4, + color: GRAY5, + border: 'none', + }, + '.cm-activeLineGutter': { + backgroundColor: `rgba(${GRAY1}, 0.6)`, + }, + '.cm-lineNumbers .cm-gutterElement': { + padding: '0 13px 0 21px', + minWidth: '0', + }, + '.cm-placeholder': { + color: '#657b83', + opacity: '0.7', + fontStyle: 'italic', + fontFamily: 'arial', + transform: 'scale(0.9)', + transformOrigin: 'left', + }, + '&.cm-focused .cm-matchingBracket': { + backgroundColor: 'transparent', + outline: '1px solid rgba(147, 161, 161, 0.5)', + }, + '&.cm-focused .cm-nonmatchingBracket': { + backgroundColor: 'transparent', + }, + '.cm-panels': { + backgroundColor: DARK_GRAY4, + }, + + // Autocomplete + '.cm-tooltip': { + border: 'none', + borderRadius: '2px', + backgroundColor: DARK_GRAY4, + boxShadow: POPUP_SHADOW, + }, + '.cm-tooltip.cm-tooltip-autocomplete > ul': { + width: '300px', + maxHeight: `${8 * 1.4}em`, + fontFamily: MONOSPACE_FONT, + fontSize: '12px', + lineHeight: '1.4', + color: '#d4d4d4', + }, + '.cm-tooltip.cm-tooltip-autocomplete > ul > li': { + display: 'flex', + padding: '0 4px', + lineHeight: '1.4', + }, + '.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover': { + backgroundColor: 'rgba(58, 103, 78, 0.62)', + boxShadow: 'inset 0 0 0 1px rgba(109, 150, 13, 0.8)', + }, + '.cm-tooltip-autocomplete ul li[aria-selected]': { + backgroundColor: '#3a674e', + color: 'inherit', + }, + '.cm-completionLabel': { + flex: '0 1 auto', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + '.cm-completionMatchedText': { + textDecoration: 'none', + color: '#a2de14', + }, + '.cm-completionDetail': { + flex: 'none', + marginLeft: 'auto', + paddingLeft: '0.9em', + fontStyle: 'normal', + opacity: '0.5', + }, + '.cm-tooltip.cm-completionInfo': { + boxSizing: 'border-box', + width: '500px', + maxWidth: 'none', + padding: '10px', + whiteSpace: 'initial', + color: '#c1ccd5', + backgroundColor: DARK_GRAY4, + boxShadow: POPUP_SHADOW, + }, + '.cm-completionInfo > *': { + filter: 'brightness(1.1)', + }, + '.cm-completionInfo .doc-name': { + fontSize: '18px', + borderBottom: '2px solid rgba(193, 204, 213, 0.5)', + paddingBottom: '4px', + color: '#93ca12', + }, + '.cm-completionInfo .doc-syntax': { + paddingTop: '8px', + paddingBottom: '10px', + }, + '.cm-completionInfo .doc-name, .cm-completionInfo .doc-syntax': { + fontFamily: MONOSPACE_FONT, + }, + }, + { dark: true }, +); + +/** + * Pads the text by the given amount (the default is a bit of horizontal padding) + */ +export function codeEditorPaddingTheme(padding: number) { + // The selectors are more specific than the ones in codeEditorTheme so that they win + return EditorView.theme({ + '.cm-scroller .cm-content': { + padding: `${padding}px 0`, + }, + '.cm-content .cm-line': { + padding: `0 ${padding}px`, + }, + }); +} + +// The colors were brightened from the solarized_dark ones with `filter: brightness(1.5) saturate(0.9)` +export const codeEditorHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: '#c8e315' }, + { tag: tags.function(tags.variableName), color: '#45cef7' }, + { tag: [tags.atom, tags.escape], color: '#facd14' }, + { tag: tags.typeName, color: '#49f943' }, + { tag: tags.number, color: '#f256bc' }, + { tag: tags.string, color: '#4deee1' }, + { tag: tags.comment, color: '#9ab8c3', fontStyle: 'italic' }, + { + tag: editorTags.issue, + color: '#f04d29', + fontStyle: 'italic', + textDecoration: 'underline wavy', + }, + { tag: editorTags.column, color: '#51fbfb' }, +]); diff --git a/web-console/src/bootstrap/ace.ts b/web-console/src/components/code-editor/code-editor.scss similarity index 66% rename from web-console/src/bootstrap/ace.ts rename to web-console/src/components/code-editor/code-editor.scss index d68044b5bd49..cd5335819b09 100644 --- a/web-console/src/bootstrap/ace.ts +++ b/web-console/src/components/code-editor/code-editor.scss @@ -16,13 +16,8 @@ * limitations under the License. */ -// Import Ace editor and all the sub components used in the app. Everything is bundled so Ace never needs to load -// anything at runtime. -import 'ace-builds'; -import 'ace-builds/src-noconflict/ext-language_tools'; -import 'ace-builds/src-noconflict/ext-searchbox'; -import 'ace-builds/src-noconflict/theme-solarized_dark'; -import '../ace-modes/dsql'; -import '../ace-modes/hjson'; - -import './ace.scss'; +// Like the Ace editor that this replaced, the editor never grows past the size it is given +.code-editor { + position: relative; + overflow: hidden; +} diff --git a/web-console/src/components/code-editor/code-editor.tsx b/web-console/src/components/code-editor/code-editor.tsx new file mode 100644 index 000000000000..c713a54188da --- /dev/null +++ b/web-console/src/components/code-editor/code-editor.tsx @@ -0,0 +1,432 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { + acceptCompletion, + autocompletion, + closeBrackets, + closeBracketsKeymap, +} from '@codemirror/autocomplete'; +import { + defaultKeymap, + history, + historyField, + historyKeymap, + indentLess, + indentMore, +} from '@codemirror/commands'; +import { bracketMatching, indentUnit, syntaxHighlighting } from '@codemirror/language'; +import { search, searchKeymap } from '@codemirror/search'; +import type { ChangeSpec, Extension } from '@codemirror/state'; +import { + Annotation, + Compartment, + countColumn, + EditorSelection, + EditorState, +} from '@codemirror/state'; +import type { Command, Rect } from '@codemirror/view'; +import { + drawSelection, + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + keymap, + lineNumbers, + placeholder as placeholderExtension, + tooltips, +} from '@codemirror/view'; +import classNames from 'classnames'; +import type React from 'react'; +import { useEffect, useEffectEvent, useImperativeHandle, useLayoutEffect, useRef } from 'react'; + +import type { EditorCompletion } from '../../editor-completions/editor-completion'; +import { dsqlLanguage } from '../../editor-modes/dsql'; +import { hjsonLanguage } from '../../editor-modes/hjson'; +import { useConstant, usePermanentCallback } from '../../hooks'; +import { EditorStateCache } from '../../singletons/editor-state-cache'; +import type { RowColumn } from '../../utils'; + +import { + codeEditorHighlightStyle, + codeEditorPaddingTheme, + codeEditorTheme, +} from './code-editor-theme'; + +import './code-editor.scss'; + +const TAB_SIZE = 2; + +// The characters that make up the word being completed +const PREFIX_REGEXP = /[\w$\-\u00A2-\u2000\u2070-\uFFFF]*/; +const VALID_PREFIX_REGEXP = new RegExp(`^${PREFIX_REGEXP.source}$`); + +/** + * Marks the changes that come from a new value being passed in (as opposed to the user editing the text) + */ +const externalChange = Annotation.define(); + +export type CodeEditorMode = 'dsql' | 'hjson' | 'text'; + +export interface CompletionRequest { + allText: string; + /** The (partial) word being completed */ + prefix: string; + /** The character right before the prefix ('\n' at the start of a line, '' at the start of the text) */ + charBeforePrefix: string; + /** All of the text before charBeforePrefix */ + textBeforePrefix: string; + /** The part of the line before charBeforePrefix */ + lineBeforePrefix: string; +} + +export interface CodeEditorProps { + ref?: React.Ref; + className?: string; + value: string; + /** Without an onChange the editor is read only */ + onChange?: (value: string) => void; + onBlur?: () => void; + mode?: CodeEditorMode; + autoFocus?: boolean; + width?: string; + height?: string; + showGutter?: boolean; + /** Pads the text on all sides */ + padding?: number; + transparentBackground?: boolean; + placeholder?: string; + getCompletions?: (request: CompletionRequest) => readonly EditorCompletion[]; + /** Makes the editor remember its state (undo history, selection) between mounts */ + stateCacheId?: string; + /** Additional extensions, only read when the editor is created */ + extensions?: Extension; +} + +let tooltipHost: HTMLElement | undefined; + +/** + * Like Ace, show the tooltips (like the autocomplete list) in the body so that they are not clipped by the containers of + * the editor. All editors share one host element that is put at the start of the body to stay out of the way of the + * elements (like Blueprint portals) that are added to the end of it. + */ +function getTooltipHost(): HTMLElement { + if (!tooltipHost) { + tooltipHost = document.createElement('div'); + tooltipHost.className = 'code-editor-tooltips'; + document.body.prepend(tooltipHost); + } + return tooltipHost; +} + +function modeExtension(mode: CodeEditorMode | undefined): Extension { + switch (mode) { + case 'dsql': + return [dsqlLanguage, closeBrackets(), keymap.of(closeBracketsKeymap)]; + + case 'hjson': + return hjsonLanguage; + + default: + return []; + } +} + +function gutterExtension(showGutter: boolean | undefined): Extension { + return showGutter ? [lineNumbers(), highlightActiveLineGutter()] : []; +} + +function readOnlyExtension(readOnly: boolean): Extension { + return EditorState.readOnly.of(readOnly); +} + +function placeholderTextExtension(placeholder: string | undefined): Extension { + return placeholder ? placeholderExtension(placeholder) : []; +} + +/** + * Like Ace: with nothing selected Tab inserts spaces up to the next tab stop, otherwise it indents the selected lines + */ +const insertSoftTab: Command = view => { + const { state } = view; + if (state.readOnly) return false; + if (state.selection.ranges.some(range => !range.empty)) return indentMore(view); + + view.dispatch( + state.changeByRange(range => { + const line = state.doc.lineAt(range.head); + const column = countColumn(line.text.slice(0, range.head - line.from), TAB_SIZE); + const spaces = ' '.repeat(TAB_SIZE - (column % TAB_SIZE)); + return { + changes: { from: range.head, insert: spaces }, + range: EditorSelection.cursor(range.head + spaces.length), + }; + }), + { scrollIntoView: true, userEvent: 'input.indent' }, + ); + return true; +}; + +/** + * Like Ace: show the documentation next to the completion list, aligned with its top + */ +function positionInfo(_view: EditorView, list: Rect, _option: Rect, info: Rect, space: Rect) { + const infoWidth = info.right - info.left; + const infoHeight = info.bottom - info.top; + const spaceLeft = list.left - space.left; + const spaceRight = space.right - list.right; + const left = spaceRight < infoWidth && spaceLeft > spaceRight; + const top = Math.max(space.top, Math.min(list.top, space.bottom - infoHeight)) - list.top; + return { + style: `top: ${top}px`, + class: left ? 'cm-completionInfo-left' : 'cm-completionInfo-right', + }; +} + +function toCompletion({ value, caption, score, meta, docHTML }: EditorCompletion): Completion { + return { + label: value, + displayLabel: caption, + detail: meta, + boost: score ? Math.max(-99, Math.min(99, score)) : undefined, + info: docHTML + ? () => { + const doc = document.createElement('div'); + doc.innerHTML = docHTML; + return doc; + } + : undefined, + }; +} + +/** + * The smallest change that turns one string into the other + */ +function diffStrings(from: string, to: string): ChangeSpec { + const minLength = Math.min(from.length, to.length); + let start = 0; + while (start < minLength && from[start] === to[start]) start++; + let fromEnd = from.length; + let toEnd = to.length; + while (fromEnd > start && toEnd > start && from[fromEnd - 1] === to[toEnd - 1]) { + fromEnd--; + toEnd--; + } + return { from: start, to: fromEnd, insert: to.slice(start, toEnd) }; +} + +/** + * Focuses the editor and puts the cursor at the given (0 based) row and column + */ +export function focusEditorAt(view: EditorView, { row, column }: RowColumn): void { + const { doc } = view.state; + const line = doc.line(Math.min(Math.max(row + 1, 1), doc.lines)); + view.focus(); + view.dispatch({ + selection: { anchor: Math.min(line.from + Math.max(column, 0), line.to) }, + scrollIntoView: true, + }); +} + +export function CodeEditor(props: CodeEditorProps) { + const { + ref, + className, + value, + onChange, + onBlur, + mode, + autoFocus, + width, + height, + showGutter, + padding, + transparentBackground, + placeholder, + getCompletions, + stateCacheId, + extensions, + } = props; + + const containerRef = useRef(null); + const viewRef = useRef(undefined); + const compartments = useConstant(() => ({ + mode: new Compartment(), + readOnly: new Compartment(), + gutter: new Compartment(), + placeholder: new Compartment(), + })); + const readOnly = !onChange; + + const handleChange = usePermanentCallback((newValue: string) => onChange?.(newValue)); + const handleBlur = usePermanentCallback(() => onBlur?.()); + const handleCompletions = usePermanentCallback( + (context: CompletionContext): CompletionResult | null => { + if (!getCompletions) return null; + const { state, pos } = context; + const from = context.matchBefore(PREFIX_REGEXP)?.from ?? pos; + const prefix = state.sliceDoc(from, pos); + if (!prefix && !context.explicit) return null; + + const line = state.doc.lineAt(from); + const completions = getCompletions({ + allText: state.doc.toString(), + prefix, + charBeforePrefix: state.sliceDoc(from - 1, from), + textBeforePrefix: state.sliceDoc(0, Math.max(0, from - 1)), + lineBeforePrefix: from > line.from ? state.sliceDoc(line.from, from - 1) : '', + }); + // Do not suggest what has already been typed (it shows up as a reference in the text) + const options = completions.filter(c => c.value !== prefix).map(toCompletion); + if (!options.length) return null; + + return { + from, + options, + validFor: VALID_PREFIX_REGEXP, + }; + }, + ); + + const createView = useEffectEvent((container: HTMLElement) => { + const editorExtensions: Extension = [ + history(), + drawSelection(), + bracketMatching(), + search(), + highlightActiveLine(), + syntaxHighlighting(codeEditorHighlightStyle), + autocompletion({ + override: [handleCompletions], + icons: false, + positionInfo, + }), + tooltips({ parent: getTooltipHost() }), + keymap.of([ + { key: 'Tab', run: acceptCompletion }, + { key: 'Tab', run: insertSoftTab, shift: indentLess }, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ]), + EditorState.tabSize.of(TAB_SIZE), + indentUnit.of(' '.repeat(TAB_SIZE)), + codeEditorTheme, + typeof padding === 'number' ? codeEditorPaddingTheme(padding) : [], + compartments.mode.of(modeExtension(mode)), + compartments.readOnly.of(readOnlyExtension(readOnly)), + compartments.gutter.of(gutterExtension(showGutter)), + compartments.placeholder.of(placeholderTextExtension(placeholder)), + EditorView.updateListener.of(update => { + if (!update.docChanged) return; + if (update.transactions.every(tr => tr.annotation(externalChange))) return; + handleChange(update.state.doc.toString()); + }), + EditorView.domEventHandlers({ blur: () => handleBlur() }), + extensions ?? [], + ]; + + let state: EditorState | undefined; + const cachedState = stateCacheId ? EditorStateCache.getState(stateCacheId) : undefined; + if (cachedState) { + try { + state = EditorState.fromJSON( + cachedState, + { extensions: editorExtensions }, + { history: historyField }, + ); + } catch { + // Fall back to a fresh state + } + } + state ??= EditorState.create({ doc: value, extensions: editorExtensions }); + + const view = new EditorView({ state, parent: container }); + const doc = state.doc.toString(); + if (doc !== value) { + view.dispatch({ + changes: diffStrings(doc, value), + annotations: externalChange.of(true), + }); + } + if (autoFocus) view.focus(); + return view; + }); + + const saveState = useEffectEvent((view: EditorView) => { + if (!stateCacheId) return; + EditorStateCache.saveState(stateCacheId, view.state.toJSON({ history: historyField })); + }); + + useLayoutEffect(() => { + if (!containerRef.current) return; + const view = createView(containerRef.current); + viewRef.current = view; + return () => { + saveState(view); + view.destroy(); + viewRef.current = undefined; + }; + }, [stateCacheId]); + + useImperativeHandle(ref, () => viewRef.current); + + useEffect(() => { + const view = viewRef.current; + if (!view) return; + const doc = view.state.doc.toString(); + if (doc === value) return; + view.dispatch({ + changes: diffStrings(doc, value), + annotations: externalChange.of(true), + }); + }, [value]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: compartments.mode.reconfigure(modeExtension(mode)), + }); + }, [compartments, mode]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: compartments.readOnly.reconfigure(readOnlyExtension(readOnly)), + }); + }, [compartments, readOnly]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: compartments.gutter.reconfigure(gutterExtension(showGutter)), + }); + }, [compartments, showGutter]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: compartments.placeholder.reconfigure(placeholderTextExtension(placeholder)), + }); + }, [compartments, placeholder]); + + return ( +
+ ); +} diff --git a/web-console/src/components/index.ts b/web-console/src/components/index.ts index 0ce78f769de5..f87058760d25 100644 --- a/web-console/src/components/index.ts +++ b/web-console/src/components/index.ts @@ -25,6 +25,7 @@ export * from './braced-text/braced-text'; export * from './center-message/center-message'; export * from './clearable-input/clearable-input'; export * from './click-to-copy/click-to-copy'; +export * from './code-editor/code-editor'; export * from './console-table'; export * from './deferred/deferred'; export * from './druid-logo/druid-logo'; diff --git a/web-console/src/components/json-input/__snapshots__/json-input.spec.tsx.snap b/web-console/src/components/json-input/__snapshots__/json-input.spec.tsx.snap index 880ddb2c2e20..ad273d82e693 100644 --- a/web-console/src/components/json-input/__snapshots__/json-input.spec.tsx.snap +++ b/web-console/src/components/json-input/__snapshots__/json-input.spec.tsx.snap @@ -5,11 +5,14 @@ exports[`JsonInput matches snapshot (null) 1`] = ` class="json-input" >
- +
+ +
`; @@ -19,11 +22,14 @@ exports[`JsonInput matches snapshot (value) 1`] = ` class="json-input" >
- +
+ +
`; diff --git a/web-console/src/components/json-input/json-input.tsx b/web-console/src/components/json-input/json-input.tsx index 7a73c0756503..cee831b5225c 100644 --- a/web-console/src/components/json-input/json-input.tsx +++ b/web-console/src/components/json-input/json-input.tsx @@ -16,16 +16,16 @@ * limitations under the License. */ -import type { Ace } from 'ace-builds'; +import type { EditorView } from '@codemirror/view'; import classNames from 'classnames'; import Hjson from 'hjson'; import * as JSONBig from 'json-bigint-native'; -import React, { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; -import AceEditor from 'react-ace'; +import React, { useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'; -import { getHjsonCompletions } from '../../ace-completions/hjson-completions'; -import { usePermanentCallback } from '../../hooks'; +import { getHjsonCompletions } from '../../editor-completions/hjson-completions'; import type { JsonCompletionRule } from '../../utils'; +import type { CompletionRequest } from '../code-editor/code-editor'; +import { CodeEditor, focusEditorAt } from '../code-editor/code-editor'; import './json-input.scss'; @@ -98,7 +98,7 @@ export const JsonInput = React.memo(function JsonInput(props: JsonInputProps) { stringified: stringifyJson(value), })); const [showErrorIfNeeded, setShowErrorIfNeeded] = useState(false); - const aceEditor = useRef(undefined); + const editorViewRef = useRef(undefined); const showValue = useEffectEvent((value: any) => { if (deepEqual(value, internalValue.lastShownValue)) return; @@ -112,105 +112,78 @@ export const JsonInput = React.memo(function JsonInput(props: JsonInputProps) { showValue(value); }, [value]); - // Ace reads the completers once, when autocompletion is enabled, so they must not change. The callback always sees - // the latest props. - const getCompletions = usePermanentCallback( - (_editor, session, pos, prefix, callback) => { - if (!jsonCompletions) { - callback(null, []); - return; - } - const allText = session.getValue(); - const line = session.getLine(pos.row); - const charBeforePrefix = line[pos.column - prefix.length - 1]; - - const lines = allText.split('\n').slice(0, pos.row + 1); - const lastLineIndex = lines.length - 1; - lines[lastLineIndex] = lines[lastLineIndex].slice(0, pos.column - prefix.length - 1); - callback( - null, - getHjsonCompletions({ - jsonCompletions, - textBefore: lines.join('\n'), - charBeforePrefix, - prefix, - }), - ); + const getCompletions = useCallback( + ({ prefix, charBeforePrefix, textBeforePrefix }: CompletionRequest) => { + if (!jsonCompletions) return []; + return getHjsonCompletions({ + jsonCompletions, + textBefore: textBeforePrefix, + charBeforePrefix, + prefix, + }); }, + [jsonCompletions], ); - const completers = useMemo(() => [{ getCompletions }], [getCompletions]); + + const handleInputChange = (inputJson: string) => { + let value: any; + let error: Error | undefined; + try { + value = parseHjson(inputJson); + } catch (e) { + error = e; + } + + if (!error && issueWithValue) { + const issue = issueWithValue(value); + if (issue) { + value = undefined; + error = new Error(issue); + } + } + + setInternalValue({ + lastShownValue: value ?? internalValue.lastShownValue, + error, + stringified: inputJson, + }); + + setError?.(error); + if (!error) { + onChange?.(value); + } + + if (showErrorIfNeeded) { + setShowErrorIfNeeded(false); + } + }; const internalValueError = internalValue.error; return (
- { - let value: any; - let error: Error | undefined; - try { - value = parseHjson(inputJson); - } catch (e) { - error = e; - } - - if (!error && issueWithValue) { - const issue = issueWithValue(value); - if (issue) { - value = undefined; - error = new Error(issue); - } - } - - setInternalValue({ - lastShownValue: value ?? internalValue.lastShownValue, - error, - stringified: inputJson, - }); - - setError?.(error); - if (!error) { - onChange?.(value); - } - - if (showErrorIfNeeded) { - setShowErrorIfNeeded(false); - } - }} + onChange={onChange ? handleInputChange : undefined} onBlur={() => setShowErrorIfNeeded(true)} - readOnly={!onChange} - focus={focus} - fontSize={12} + autoFocus={focus} width={width || '100%'} height={height || '8vh'} - showPrintMargin={false} showGutter={Boolean(showLineNumbers)} value={internalValue.stringified} placeholder={placeholder} - editorProps={{ completers }} - enableBasicAutocompletion={Boolean(jsonCompletions)} - enableLiveAutocompletion={Boolean(jsonCompletions)} - setOptions={{ - showLineNumbers: Boolean(showLineNumbers), - tabSize: 2, - newLineMode: 'unix', - }} - onLoad={editor => { - aceEditor.current = editor; - }} + getCompletions={jsonCompletions ? getCompletions : undefined} /> {showErrorIfNeeded && internalValueError && (
{ - if (!aceEditor.current || !internalValueError) return; + if (!editorViewRef.current || !internalValueError) return; const rc = extractRowColumnFromHjsonError(internalValueError); if (!rc) return; - aceEditor.current.focus(); // Grab the focus - aceEditor.current.getSelection().moveCursorTo(rc.row, rc.column); + focusEditorAt(editorViewRef.current, rc); }} > {internalValueError.message} diff --git a/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx b/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx index 23295073de1c..ff9c1bccb0bb 100644 --- a/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx +++ b/web-console/src/components/show-json-or-stages/show-json-or-stages.tsx @@ -19,13 +19,13 @@ import { Button, ButtonGroup, Intent } from '@blueprintjs/core'; import * as JSONBig from 'json-bigint-native'; import React from 'react'; -import AceEditor from 'react-ace'; import { Execution } from '../../druid-models'; import { useQueryManager } from '../../hooks'; import { Api, AppToaster, UrlBaser } from '../../singletons'; import { copyToClipboard, downloadFile } from '../../utils'; import { ExecutionStagesPane } from '../../views/workbench-view/execution-stages-pane/execution-stages-pane'; +import { CodeEditor } from '../code-editor/code-editor'; import { Loader } from '../loader/loader'; import './show-json-or-stages.scss'; @@ -108,16 +108,10 @@ export const ShowJsonOrStages = React.memo(function ShowJsonOrStages(props: Show ) : execution ? ( ) : ( - )}
diff --git a/web-console/src/components/show-json/show-json.tsx b/web-console/src/components/show-json/show-json.tsx index f9fa6f6a31c2..85b33906db03 100644 --- a/web-console/src/components/show-json/show-json.tsx +++ b/web-console/src/components/show-json/show-json.tsx @@ -19,11 +19,11 @@ import { Button, ButtonGroup, Intent } from '@blueprintjs/core'; import * as JSONBig from 'json-bigint-native'; import React from 'react'; -import AceEditor from 'react-ace'; import { useQueryManager } from '../../hooks'; import { Api, AppToaster, UrlBaser } from '../../singletons'; import { copyToClipboard, downloadFile } from '../../utils'; +import { CodeEditor } from '../code-editor/code-editor'; import { Loader } from '../loader/loader'; import './show-json.scss'; @@ -90,16 +90,10 @@ export const ShowJson = React.memo(function ShowJson(props: ShowJsonProps) { {jsonState.loading ? ( ) : ( - )}
diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx index a47c71f704e7..10d82ed71515 100644 --- a/web-console/src/console-application.tsx +++ b/web-console/src/console-application.tsx @@ -22,10 +22,10 @@ import classNames from 'classnames'; import type { JSX } from 'react'; import React from 'react'; -import { initAceDsqlMode } from './ace-modes/dsql'; import { HeaderBar, Loader } from './components'; import { SqlFunctionsProvider } from './contexts/sql-functions-context'; import type { ConsoleViewId, QueryContext, QueryWithContext } from './druid-models'; +import { initDsqlMode } from './editor-modes/dsql'; import type { AvailableFunctions } from './helpers'; import { Capabilities, maybeGetClusterCapacity } from './helpers'; import { AppToaster } from './singletons'; @@ -154,7 +154,7 @@ export class ConsoleApplication extends React.PureComponent< } const capabilities = data?.[0] || Capabilities.FULL; const availableSqlFunctions = data?.[1]; - initAceDsqlMode(availableSqlFunctions); + initDsqlMode(availableSqlFunctions); this.setState({ capabilities, availableSqlFunctions, diff --git a/web-console/src/dialogs/edit-context-dialog/__snapshots__/edit-context-dialog.spec.tsx.snap b/web-console/src/dialogs/edit-context-dialog/__snapshots__/edit-context-dialog.spec.tsx.snap index 7588c8ec88a0..f34d6f9cc7d6 100644 --- a/web-console/src/dialogs/edit-context-dialog/__snapshots__/edit-context-dialog.spec.tsx.snap +++ b/web-console/src/dialogs/edit-context-dialog/__snapshots__/edit-context-dialog.spec.tsx.snap @@ -53,11 +53,14 @@ exports[`EditContextDialog matches snapshot 1`] = ` class="json-input" >
- +
+ +
- +
+ +
diff --git a/web-console/src/dialogs/show-value-dialog/show-value-dialog.scss b/web-console/src/dialogs/show-value-dialog/show-value-dialog.scss index 6ba7ebdc5297..e7056f82aa13 100644 --- a/web-console/src/dialogs/show-value-dialog/show-value-dialog.scss +++ b/web-console/src/dialogs/show-value-dialog/show-value-dialog.scss @@ -32,7 +32,7 @@ display: flex; flex-direction: column; - .ace-editor { + .code-editor { flex: 1; } diff --git a/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx b/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx index 2f7a19307ae9..034b25f3e66b 100644 --- a/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx +++ b/web-console/src/dialogs/show-value-dialog/show-value-dialog.tsx @@ -29,8 +29,8 @@ import { IconNames } from '@blueprintjs/icons'; import classNames from 'classnames'; import * as JSONBig from 'json-bigint-native'; import React, { useMemo, useState } from 'react'; -import AceEditor from 'react-ace'; +import { CodeEditor } from '../../components'; import { AppToaster } from '../../singletons'; import { copyToClipboard } from '../../utils'; @@ -84,17 +84,12 @@ export const ShowValueDialog = React.memo(function ShowValueDialog(props: ShowVa )} {hasParsed && tab === 'formatted' && ( - )} {(!hasParsed || tab === 'raw') &&