From c53ff0de671ddc60676009db38e31972eb4dcdfe Mon Sep 17 00:00:00 2001 From: Jan De Dobbeleer Date: Sat, 1 Aug 2026 12:32:31 +0200 Subject: [PATCH] feat(studio): rebuild config editor on CodeMirror 6 The hand-rolled completion engine could not complete YAML key positions: its cursor-marker probe produced invalid YAML mid-document, the parser folded the marker into the next line's key, and the popup never opened. Instead of patching the probe, replace the editor foundation: CodeMirror 6 provides the popup, gutter, hover, and tooltip machinery natively. codemirror-json-schema was evaluated for the schema features and rejected: enum completion through $ref resolves to nothing, $ref nodes with sibling keywords crash its resolver, and both its published ESM and CJS builds are broken under strict module resolution. The proven schema resolver from the previous engine stays, rewired as a native completion/hover source that walks the lezer syntax tree instead of scanning text - which makes mid-edit states (blank lines, dangling keys, open strings) work in both JSON and YAML, including per-segment-type options completion. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 9045963ce7e2 --- website/package-lock.json | 195 +++- website/package.json | 11 +- .../src/components/ConfigEditor/completion.js | 789 -------------- .../ConfigEditor/editorExtensions.js | 45 + .../components/ConfigEditor/editorTheme.js | 105 ++ .../components/ConfigEditor/externalError.js | 113 ++ website/src/components/ConfigEditor/index.js | 968 ++++-------------- .../ConfigEditor/schemaCompletion.js | 805 +++++++++++++++ .../ConfigEditor/schemaResolution.js | 214 ++++ .../components/ConfigEditor/styles.module.css | 205 +--- 10 files changed, 1696 insertions(+), 1754 deletions(-) delete mode 100644 website/src/components/ConfigEditor/completion.js create mode 100644 website/src/components/ConfigEditor/editorExtensions.js create mode 100644 website/src/components/ConfigEditor/editorTheme.js create mode 100644 website/src/components/ConfigEditor/externalError.js create mode 100644 website/src/components/ConfigEditor/schemaCompletion.js create mode 100644 website/src/components/ConfigEditor/schemaResolution.js diff --git a/website/package-lock.json b/website/package-lock.json index 1cfd00b74..dfd0519ae 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -8,14 +8,23 @@ "name": "website", "version": "0.0.0", "dependencies": { + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.10.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/lint": "^6.9.7", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.7", "@docusaurus/core": "^3.10.2", "@docusaurus/preset-classic": "^3.10.2", "@docusaurus/theme-search-algolia": "^3.10.2", + "@lezer/highlight": "^1.2.3", "@mdx-js/react": "^3.1.1", "classnames": "^2.5.1", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-simple-code-editor": "^0.14.1", "smol-toml": "^1.7.0", "yaml": "^2.9.0" }, @@ -1994,6 +2003,110 @@ "node": ">=6.9.0" } }, + "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.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "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/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.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/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.7", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.7.tgz", + "integrity": "sha512-FZsExxkoxnAN+d9TgqXLg5g4A1oQwzX9WlkOT5i2PKkcW7xx3Bmu0vs90g6fo9Mpdsb/l96dnAraQ8932aO4/g==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -4343,6 +4456,58 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.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/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -6762,6 +6927,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-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -15000,16 +15171,6 @@ "react": ">=15" } }, - "node_modules/react-simple-code-editor": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz", - "integrity": "sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -16392,6 +16553,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -17253,6 +17420,12 @@ "url": "https://opencollective.com/unified" } }, + "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/watchpack": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.0.tgz", diff --git a/website/package.json b/website/package.json index 6c28de9a9..8d85bf2bd 100644 --- a/website/package.json +++ b/website/package.json @@ -14,14 +14,23 @@ "clear": "docusaurus clear" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.10.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/lint": "^6.9.7", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.7", "@docusaurus/core": "^3.10.2", "@docusaurus/preset-classic": "^3.10.2", "@docusaurus/theme-search-algolia": "^3.10.2", + "@lezer/highlight": "^1.2.3", "@mdx-js/react": "^3.1.1", "classnames": "^2.5.1", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-simple-code-editor": "^0.14.1", "smol-toml": "^1.7.0", "yaml": "^2.9.0" }, diff --git a/website/src/components/ConfigEditor/completion.js b/website/src/components/ConfigEditor/completion.js deleted file mode 100644 index 0b3e051e2..000000000 --- a/website/src/components/ConfigEditor/completion.js +++ /dev/null @@ -1,789 +0,0 @@ -import schema from '../../../../themes/schema.json'; - -const ROOT_SCHEMA = schema; - -// The main scanner in getCompletionContext only walks text up to the cursor, so its -// per-frame usedKeys only ever sees sibling keys that appear BEFORE the cursor. When -// completing inside an already-populated object (the common "loaded an existing config" -// case), sibling keys placed AFTER the cursor are just as real and must be excluded too. -// This does a small forward-only scan from the cursor to the end of the current object -// (its matching closing `}`), collecting any key strings it passes at the same depth. -function collectForwardKeys(text, cursorOffset, insideOpenString) { - const keys = []; - let i = cursorOffset; - - // If the cursor sits inside a string we (or the user) already opened, find where that - // string would close - but only treat it as a genuine closer if what follows it looks - // like a key delimiter (`:`). Otherwise the "next quote" is really the start of an - // unrelated sibling key sitting right next to our not-yet-closed string (e.g. the user - // just typed a bare opening `"` for a brand new key immediately before an existing - // one), and skipping past it would swallow that sibling's own opening quote. - if (insideOpenString) { - let closeAt = i; - while (closeAt < text.length && text[closeAt] !== '"') { - if (text[closeAt] === '\\') { - closeAt += 1; - } - closeAt += 1; - } - - if (closeAt < text.length) { - let k = closeAt + 1; - while (k < text.length && /\s/.test(text[k])) { - k += 1; - } - if (text[k] === ':') { - i = closeAt + 1; - } - } - } - - let depth = 0; - let inString = false; - - while (i < text.length) { - const char = text[i]; - - if (inString) { - if (char === '\\') { - i += 2; - continue; - } - if (char === '"') { - inString = false; - } - i += 1; - continue; - } - - if (char === '"') { - const keyStart = i + 1; - let j = keyStart; - while (j < text.length && text[j] !== '"') { - if (text[j] === '\\') { - j += 1; - } - j += 1; - } - - if (depth === 0) { - // Only a string immediately followed by `:` (ignoring whitespace) is a key - - // a plain string value at this depth must not be collected as one. - let k = j + 1; - while (k < text.length && /\s/.test(text[k])) { - k += 1; - } - if (text[k] === ':') { - keys.push(text.slice(keyStart, j)); - } - } - - i = j + 1; - continue; - } - - if (char === '{' || char === '[') { - depth += 1; - i += 1; - continue; - } - - if (char === '}' || char === ']') { - if (depth === 0) { - break; - } - depth -= 1; - i += 1; - continue; - } - - i += 1; - } - - return keys; -} - -function resolveSchema(node, root = ROOT_SCHEMA) { - if (!node || typeof node !== 'object') { - return {}; - } - - if (node.$ref) { - const ref = node.$ref; - if (ref.startsWith('#/')) { - const target = ref.split('/').slice(1).reduce((acc, part) => acc?.[part], root); - const resolved = resolveSchema(target, root); - // Draft 2020-12 allows keywords alongside $ref; sibling keys (e.g. a - // description overriding the target's) must win over the target's own. - const siblings = { ...node }; - delete siblings.$ref; - return { ...resolved, ...siblings }; - } - } - - // anyOf/oneOf branches are alternatives (e.g. "enum or free string") rather than - // required composition, so completion only needs the union of their enum values. - if (node.anyOf || node.oneOf) { - const branches = node.anyOf || node.oneOf; - const merged = { ...node }; - delete merged.anyOf; - delete merged.oneOf; - - branches.forEach((branch) => { - const resolvedBranch = resolveSchema(branch, root); - if (resolvedBranch.enum) { - merged.enum = [...(merged.enum || []), ...resolvedBranch.enum]; - } - if (resolvedBranch.type && !merged.type) { - merged.type = resolvedBranch.type; - } - }); - - return merged; - } - - if (node.allOf) { - return node.allOf.reduce((acc, child) => mergeSchema(acc, resolveSchema(child, root)), { - ...node, - }); - } - - return { ...node }; -} - -function mergeSchema(base, incoming) { - const merged = { ...base }; - if (incoming.properties) { - merged.properties = { - ...(base.properties || {}), - ...(incoming.properties || {}), - }; - } - - if (incoming.type && !merged.type) { - merged.type = incoming.type; - } - - if (incoming.enum && !merged.enum) { - merged.enum = incoming.enum; - } - - if (incoming.default !== undefined && merged.default === undefined) { - merged.default = incoming.default; - } - - if (incoming.description && !merged.description) { - merged.description = incoming.description; - } - - if (incoming.title && !merged.title) { - merged.title = incoming.title; - } - - return merged; -} - -function getDefinitionSchema(ref, root = ROOT_SCHEMA) { - if (!ref || typeof ref !== 'string' || !ref.startsWith('#/')) { - return {}; - } - - const target = ref.split('/').slice(1).reduce((acc, part) => acc?.[part], root); - return resolveSchema(target, root); -} - -function deriveValueSchema(parentSchema, propertyName, root = ROOT_SCHEMA) { - const resolvedParent = resolveSchema(parentSchema, root); - const propertySchema = resolvedParent.properties?.[propertyName]; - - if (!propertySchema) { - return {}; - } - - return resolveSchema(propertySchema, root); -} - -// Only `definitions.segment` uses conditional `if.properties.type.const` / `then` -// branches today, but this stays generic so any similarly-shaped schema benefits. -function mergeTypeBranch(schema, typeValue) { - const resolved = resolveSchema(schema); - if (!resolved.allOf || !typeValue) { - return resolved; - } - - const merged = { ...resolved }; - resolved.allOf.forEach((branch) => { - if (branch.if?.properties?.type?.const === typeValue) { - const thenSchema = resolveSchema(branch.then, ROOT_SCHEMA); - const nextProperties = { ...(merged.properties || {}) }; - - Object.entries(thenSchema.properties || {}).forEach(([key, branchProperty]) => { - const baseProperty = nextProperties[key]; - // A segment's own override (e.g. "options") replaces the base property's shape, - // but shouldn't lose the base's description/title if the override doesn't set one. - nextProperties[key] = baseProperty - ? { description: baseProperty.description, title: baseProperty.title, ...branchProperty } - : branchProperty; - }); - - merged.properties = nextProperties; - } - }); - - return merged; -} - -function getItemSchema(parentValueSchema) { - const resolved = resolveSchema(parentValueSchema); - if (!resolved.items) { - return {}; - } - - return resolveSchema(resolved.items); -} - -// A resolved schema's own "type" keyword is the most direct source, but per-segment-type -// conditionals (schema.json's "if type === X then properties: { options: {...} }" branches) -// routinely narrow a property down to just "properties"/"items"/"unevaluatedProperties" -// without repeating "type": "object" (JSON Schema doesn't require it for validation to work). -// Completion still needs a concrete type to know what to chain into, so fall back to -// inferring it from shape - "properties" implies object, "items" implies array - before -// falling back further to a bare enum implying string. -function inferSchemaType(resolvedSchema) { - if (resolvedSchema.type) { - return resolvedSchema.type; - } - - if (resolvedSchema.properties) { - return 'object'; - } - - if (resolvedSchema.items) { - return 'array'; - } - - return resolvedSchema.enum ? 'string' : null; -} - -function createObjectFrame(schema) { - return { - kind: 'object', - schema: resolveSchema(schema), - lastPropertyName: null, - inValue: false, - expectingKey: true, - pendingValueSchema: null, - usedKeys: [], - }; -} - -function createArrayFrame(itemSchema) { - return { - kind: 'array', - schema: itemSchema, - lastPropertyName: null, - inValue: false, - expectingKey: false, - pendingValueSchema: null, - // Array items are never keys, so this never gains entries, but it must exist - - // getCompletionContext spreads every frame's usedKeys regardless of frame kind. - usedKeys: [], - }; -} - -// Walks the raw text char-by-char (not a full parse — configs mid-edit are rarely -// valid JSON) tracking a stack of object/array frames so we know which schema -// applies at the cursor. Handles the "still typing, string not closed yet" case -// explicitly since that's the state completion is actually triggered in. -// Which schema governs the outermost `{` of the text being edited. The studio edits a full -// theme config (ROOT_SCHEMA fits directly); a segment doc page's sample editor only ever -// contains a single bare segment object (see website/src/components/Config.js), so completion -// there needs to start from #/definitions/segment instead - otherwise every top-level -// property/hover lookup resolves against the wrong schema entirely (root config fields like -// "final_space" instead of segment fields like "foreground"). -function getScopedRootSchema(schemaScope) { - if (schemaScope === 'segment') { - return getDefinitionSchema('#/definitions/segment', ROOT_SCHEMA); - } - return ROOT_SCHEMA; -} - -function getCompletionContext(text, cursorOffset, schemaScope = 'config') { - const scopedRootSchema = getScopedRootSchema(schemaScope); - const beforeCursor = text.slice(0, cursorOffset); - const stack = []; - let currentFrame = null; - - let inString = false; - let escapeNext = false; - let currentString = ''; - let stringStartedAsKey = false; - - for (let index = 0; index < beforeCursor.length; index += 1) { - const char = beforeCursor[index]; - - if (inString) { - if (escapeNext) { - escapeNext = false; - currentString += char; - continue; - } - - if (char === '\\') { - escapeNext = true; - currentString += char; - continue; - } - - if (char === '"') { - inString = false; - - if (stringStartedAsKey) { - currentFrame.lastPropertyName = currentString; - currentFrame.expectingKey = false; - currentFrame.inValue = false; - if (!currentFrame.usedKeys.includes(currentString)) { - currentFrame.usedKeys.push(currentString); - } - } else if (currentFrame?.kind === 'object' && currentFrame.lastPropertyName === 'type') { - currentFrame.schema = mergeTypeBranch(currentFrame.schema, currentString); - } - - currentString = ''; - } else { - currentString += char; - } - continue; - } - - if (char === '"') { - inString = true; - currentString = ''; - stringStartedAsKey = !!currentFrame && currentFrame.kind === 'object' && currentFrame.expectingKey; - continue; - } - - if (char === '{') { - // The very first `{` opens the config's own root object — there's no - // enclosing property to derive a value schema from, so it uses ROOT_SCHEMA - // directly instead of a (nonexistent) parent's pending value schema. - let frameSchema; - if (!currentFrame) { - frameSchema = scopedRootSchema; - } else if (currentFrame.kind === 'object') { - frameSchema = currentFrame.pendingValueSchema || {}; - } else { - frameSchema = currentFrame.schema; - } - - const frame = createObjectFrame(frameSchema); - stack.push(frame); - currentFrame = frame; - continue; - } - - if (char === '[') { - const parentValueSchema = !currentFrame - ? {} - : currentFrame.kind === 'object' - ? currentFrame.pendingValueSchema - : currentFrame.schema; - const frame = createArrayFrame(getItemSchema(parentValueSchema)); - stack.push(frame); - currentFrame = frame; - continue; - } - - if (char === '}' || char === ']') { - if (stack.length > 0) { - stack.pop(); - currentFrame = stack[stack.length - 1] || null; - } - continue; - } - - if (char === ':') { - if (currentFrame?.kind === 'object' && currentFrame.lastPropertyName) { - currentFrame.pendingValueSchema = deriveValueSchema(currentFrame.schema, currentFrame.lastPropertyName); - currentFrame.inValue = true; - currentFrame.expectingKey = false; - } - continue; - } - - if (char === ',') { - if (currentFrame?.kind === 'object') { - currentFrame.expectingKey = true; - currentFrame.inValue = false; - currentFrame.lastPropertyName = null; - currentFrame.pendingValueSchema = null; - } - continue; - } - } - - if (!currentFrame) { - return { - schema: scopedRootSchema, - currentPropertyName: null, - partialText: inString ? currentString : '', - inValue: false, - insideOpenString: inString, - usedKeys: [], - }; - } - - const activeFrame = currentFrame; - - if (inString) { - if (stringStartedAsKey) { - return { - schema: activeFrame.schema, - currentPropertyName: null, - partialText: currentString, - inValue: false, - insideOpenString: true, - usedKeys: [...new Set([...activeFrame.usedKeys, ...collectForwardKeys(text, cursorOffset, true)])], - }; - } - - return { - schema: activeFrame.pendingValueSchema || {}, - currentPropertyName: activeFrame.lastPropertyName, - partialText: currentString, - inValue: true, - insideOpenString: true, - usedKeys: activeFrame.usedKeys, - }; - } - - if (activeFrame.kind === 'object' && activeFrame.inValue) { - // Unlike a string value (tracked char-by-char above via currentString), a bare literal - // like a boolean's "true"/"false" leaves no trace in the walk above - so re-triggering - // completion right after one (e.g. to swap a just-seeded default, see applyCompletion's - // boolean chain) needs its own backward scan to find how much of it is replaceable. - // Deliberately NOT reflected in partialText itself: unlike a string value, filtering - // "true"/"false" by the literal that's already there would just filter the other one - // out, defeating the point of offering both. - const bareTokenMatch = /[^\s{}[\],"]+$/.exec(beforeCursor); - return { - schema: activeFrame.pendingValueSchema || {}, - currentPropertyName: activeFrame.lastPropertyName, - partialText: '', - replaceLength: bareTokenMatch ? bareTokenMatch[0].length : 0, - insideOpenString: false, - inValue: true, - usedKeys: activeFrame.usedKeys, - }; - } - - return { - schema: activeFrame.schema, - currentPropertyName: activeFrame.lastPropertyName, - partialText: '', - inValue: false, - insideOpenString: false, - usedKeys: [...new Set([...activeFrame.usedKeys, ...collectForwardKeys(text, cursorOffset, false)])], - }; -} - -function normalizeCompletionItem(item) { - return { - label: item.label, - kind: item.kind, - // Raw text only — the caller decides whether to wrap it in quotes, since - // that depends on whether the cursor is already inside an open string. - insertText: item.insertText, - needsQuotes: !!item.needsQuotes, - detail: item.detail || '', - // Only set on `property` items - tells the caller what kind of value slot follows, - // so it can chain straight into the next completion cycle instead of waiting for - // the user to type the opening character themselves. See applyCompletion (index.js). - chainValueType: item.chainValueType || null, - // Only meaningful when chainValueType === 'array' - the resolved type of the array's - // own items, so the caller can seed a useful first element (e.g. `[""]` for an array - // of strings) instead of leaving a bare `[]`. - chainItemType: item.chainItemType || null, - // Only set for scalar types (boolean/integer/number) that have a schema default - - // seeds that literal instead of leaving the value slot empty/invalid. - chainDefault: item.chainDefault !== undefined ? item.chainDefault : null, - // Richer text for the hover tooltip (index.js) - kept separate from `detail` (the - // short title already shown inline in the popup row) so the tooltip can show real - // prose without repeating it. Always plain explanatory text - schema.json no longer - // carries bare doc-link descriptions (see buildPropertyHint). - description: item.description || '', - // A short list of sample values worth showing under the description - either authored - // directly in schema.json (the "examples" keyword) or, absent that, a small enum's own - // values (see buildPropertyHint). Null when there's nothing worth showing. - examples: item.examples || null, - }; -} - -// Schema.json's "examples" keyword, when present, is the most direct source of sample -// values for a tooltip. Absent that, a small enum is itself a good stand-in - but only -// when it's short enough to be a helpful hint rather than a wall of text (large enums, -// e.g. every segment type, are already fully browsable via the completion dropdown itself). -const MAX_ENUM_HINT_SIZE = 8; - -function buildPropertyHint(resolvedProp) { - const description = resolvedProp.description && resolvedProp.description !== resolvedProp.title - ? resolvedProp.description - : ''; - - let examples = Array.isArray(resolvedProp.examples) && resolvedProp.examples.length - ? resolvedProp.examples - : null; - - if (!examples) { - const enumSource = resolvedProp.enum - || (inferSchemaType(resolvedProp) === 'array' ? getItemSchema(resolvedProp).enum : null); - if (Array.isArray(enumSource) && enumSource.length > 0 && enumSource.length <= MAX_ENUM_HINT_SIZE) { - examples = enumSource; - } - } - - return { description, examples }; -} - - -// Finds the property-key string (if any) whose quoted range spans `offset`, scanning the -// WHOLE text rather than just up to a cursor - unlike getCompletionContext, a hover target can -// sit anywhere in the document, including after the caret. Mirrors getCompletionContext's own -// object/array frame tracking, but only needs to know "is this string a key, and if so what -// object was it a key of" rather than resolve a full schema chain. -function findKeyTokenAt(text, offset) { - const stack = []; - let inString = false; - let escapeNext = false; - let stringStart = -1; - - for (let i = 0; i < text.length; i += 1) { - const char = text[i]; - - if (inString) { - if (escapeNext) { - escapeNext = false; - continue; - } - if (char === '\\') { - escapeNext = true; - continue; - } - if (char === '"') { - inString = false; - const top = stack[stack.length - 1]; - if (top && top.kind === 'object' && top.expectingKey) { - let k = i + 1; - while (k < text.length && /\s/.test(text[k])) { - k += 1; - } - if (text[k] === ':') { - if (offset >= stringStart && offset <= i + 1) { - return { keyName: text.slice(stringStart + 1, i), contextOffset: stringStart }; - } - top.expectingKey = false; - } - } - } - continue; - } - - if (char === '"') { - inString = true; - stringStart = i; - continue; - } - - if (char === '{') { - stack.push({ kind: 'object', expectingKey: true }); - continue; - } - - if (char === '[') { - stack.push({ kind: 'array', expectingKey: false }); - continue; - } - - if (char === '}' || char === ']') { - stack.pop(); - continue; - } - - if (char === ',') { - const top = stack[stack.length - 1]; - if (top && top.kind === 'object') { - top.expectingKey = true; - } - continue; - } - } - - return null; -} - -// Powers the editor's own hover tooltip (index.js) - hovering directly over a key already -// typed in the config, not just an entry in the completion popup. Resolves the same schema -// info getPropertySuggestions would have offered for that key, using getCompletionContext at -// the offset right before the key's own opening quote so the surrounding object's schema -// still has that key's own definition. -export function getHoverInfo(text, format, offset, schemaScope = 'config') { - if (format !== 'json') { - return null; - } - - const token = findKeyTokenAt(text, offset); - if (!token) { - return null; - } - - const context = getCompletionContext(text, token.contextOffset, schemaScope); - const resolvedSchema = resolveSchema(context.schema || getScopedRootSchema(schemaScope), ROOT_SCHEMA); - const propSchema = resolvedSchema.properties?.[token.keyName]; - if (!propSchema) { - return null; - } - - const resolvedProp = resolveSchema(propSchema, ROOT_SCHEMA); - const title = resolvedProp.title || token.keyName; - const { description, examples } = buildPropertyHint(resolvedProp); - - return { - title, - text: description, - examples, - }; -} - -function getPropertySuggestions(context) { - const resolvedSchema = resolveSchema(context.schema || ROOT_SCHEMA, ROOT_SCHEMA); - if (!resolvedSchema.properties) { - return []; - } - - const usedKeys = context.usedKeys || []; - - return Object.entries(resolvedSchema.properties) - .filter(([name]) => !usedKeys.includes(name)) - .map(([name, propSchema]) => { - const resolvedProp = resolveSchema(propSchema, ROOT_SCHEMA); - const detail = resolvedProp.title || resolvedProp.description || ''; - // Only worth surfacing in the tooltip when it says something detail doesn't already - - // skip it when description IS what detail fell back to (bare title-less properties). - const { description, examples } = detail === resolvedProp.description - ? { description: '', examples: null } - : buildPropertyHint(resolvedProp); - // A string-typed enum (e.g. "style") still resolves with type: "string" today because - // of how the schema expresses it (an anyOf branch with a bare "type": "string" fallback - - // see resolveSchema's anyOf handling), but inferSchemaType covers any schema shape that - // omits an explicit type (e.g. per-segment-type conditionals that only narrow - // "properties"/"items" without repeating "type": "object"/"array"). - const chainValueType = inferSchemaType(resolvedProp); - const chainItemType = chainValueType === 'array' ? inferSchemaType(getItemSchema(resolvedProp)) : null; - // Every chainable value type needs *some* concrete literal to seed so the completion - // always leaves valid JSON behind, even when the schema itself doesn't author a - // "default" - false/0 are the same harmless placeholders the editor already relies on - // elsewhere (e.g. an empty "" for strings, an empty {} for objects). - const chainDefault = chainValueType === 'boolean' - ? (resolvedProp.default !== undefined ? resolvedProp.default : false) - : chainValueType === 'integer' || chainValueType === 'number' - ? (resolvedProp.default !== undefined ? resolvedProp.default : 0) - : undefined; - return normalizeCompletionItem({ - label: name, - kind: 'property', - insertText: name, - needsQuotes: true, - detail, - description, - examples, - chainValueType, - chainItemType, - chainDefault, - }); - }); -} - -function getEnumSuggestions(context) { - const resolvedSchema = resolveSchema(context.schema || ROOT_SCHEMA, ROOT_SCHEMA); - if (resolvedSchema.enum) { - const description = resolvedSchema.description && resolvedSchema.description !== resolvedSchema.title - ? resolvedSchema.description - : ''; - return resolvedSchema.enum.map((value) => normalizeCompletionItem({ - label: value, - kind: 'value', - insertText: typeof value === 'string' ? value : JSON.stringify(value), - needsQuotes: typeof value === 'string', - detail: `Enum value for ${resolvedSchema.title || 'property'}`, - description, - })); - } - - // A boolean has exactly two possible values, so it's just as pickable as a small enum - - // offer both instead of leaving the reader to type "true"/"false" by hand. - if (inferSchemaType(resolvedSchema) === 'boolean') { - const description = resolvedSchema.description && resolvedSchema.description !== resolvedSchema.title - ? resolvedSchema.description - : ''; - return [true, false].map((value) => normalizeCompletionItem({ - label: String(value), - kind: 'value', - insertText: String(value), - needsQuotes: false, - detail: `Boolean value for ${resolvedSchema.title || 'property'}`, - description, - })); - } - - return []; -} - -function getTypeSuggestions() { - const segmentSchema = getDefinitionSchema('#/definitions/segment', ROOT_SCHEMA); - const typeSchema = segmentSchema.properties?.type; - return getEnumSuggestions({ schema: resolveSchema(typeSchema, ROOT_SCHEMA) }); -} - -function getSuggestionItems(context) { - const partialText = context.partialText || ''; - - if (context.inValue) { - if (context.currentPropertyName === 'type') { - return getTypeSuggestions().filter((item) => item.label.startsWith(partialText)); - } - - return getEnumSuggestions(context).filter((item) => item.label.startsWith(partialText)); - } - - return getPropertySuggestions(context).filter((item) => item.label.startsWith(partialText)); -} - -export function getCompletions(text, format, cursorOffset, schemaScope = 'config') { - if (format !== 'json') { - return []; - } - - const context = getCompletionContext(text, cursorOffset, schemaScope); - return getSuggestionItems(context); -} - -// Companion to getCompletions() giving the caller precise replacement bounds: how many -// already-typed characters to replace, and whether the cursor sits inside an already-open -// string (so insertText for a needsQuotes item must NOT be re-wrapped in quotes). Normally -// that's just partialText.length, but a bare-literal value slot (see getCompletionContext's -// object/inValue branch) reports a separate replaceLength - the token to discard is there, -// but deliberately isn't the same text used to filter which suggestions to show. -export function getCompletionReplacement(text, format, cursorOffset, schemaScope = 'config') { - if (format !== 'json') { - return { start: cursorOffset, insideOpenString: false }; - } - - const context = getCompletionContext(text, cursorOffset, schemaScope); - const partialText = context.partialText || ''; - const replaceLength = context.replaceLength !== undefined ? context.replaceLength : partialText.length; - return { - start: cursorOffset - replaceLength, - insideOpenString: !!context.insideOpenString, - }; -} - diff --git a/website/src/components/ConfigEditor/editorExtensions.js b/website/src/components/ConfigEditor/editorExtensions.js new file mode 100644 index 000000000..6662207c1 --- /dev/null +++ b/website/src/components/ConfigEditor/editorExtensions.js @@ -0,0 +1,45 @@ +// Language + schema-completion wiring for the editor, kept free of React so it can be unit +// tested (in principle) and reasoned about independently from the mount/update lifecycle in +// index.js. json/yaml get schema-aware completion and hover from schemaCompletion.js's own +// syntax-tree walker (see that file's header for the schema-resolution library workaround it +// replaced); toml has no such support available, so it stays a plain syntax-only mode - it was +// never covered by the old hand-rolled completion.js engine either. +import { json, jsonLanguage } from '@codemirror/lang-json'; +import { yaml, yamlLanguage } from '@codemirror/lang-yaml'; +import { StreamLanguage } from '@codemirror/language'; +import { toml } from '@codemirror/legacy-modes/mode/toml'; +import { schemaCompletionSource, schemaHoverTooltip, schemaHintTheme } from './schemaCompletion'; +import { getScopedRootSchema } from './schemaResolution'; + +// 'segment' (a segment doc's sample editor) completes against the bare segment shape; 'config' +// (the studio, and the default) completes against the full theme schema. Delegates to +// schemaResolution.js's getScopedRootSchema, which resolves the scoped shape against the schema's +// own `definitions` so its `#/definitions/...` refs still resolve regardless of scope. +export function getScopedSchema(schemaScope) { + return getScopedRootSchema(schemaScope); +} + +// One extension array per format, swapped into index.js's language Compartment whenever the +// format or schemaScope changes. +export function getLanguageExtensions(format, schemaScope) { + switch (format) { + case 'json': + return [ + json(), + jsonLanguage.data.of({ autocomplete: schemaCompletionSource(schemaScope) }), + schemaHoverTooltip(schemaScope), + schemaHintTheme, + ]; + case 'yaml': + return [ + yaml(), + yamlLanguage.data.of({ autocomplete: schemaCompletionSource(schemaScope) }), + schemaHoverTooltip(schemaScope), + schemaHintTheme, + ]; + case 'toml': + return [StreamLanguage.define(toml)]; + default: + return []; + } +} diff --git a/website/src/components/ConfigEditor/editorTheme.js b/website/src/components/ConfigEditor/editorTheme.js new file mode 100644 index 000000000..2af023e4f --- /dev/null +++ b/website/src/components/ConfigEditor/editorTheme.js @@ -0,0 +1,105 @@ +// The editor's colors, kept independent of the language extensions (editorExtensions.js) and +// swapped into index.js's own theme Compartment whenever @docusaurus/theme-common's colorMode +// flips - the same two palettes the old prism-react-renderer setup used (see the removed +// DARK_CODE_THEME/LIGHT_CODE_THEME in index.js's git history: palenight for dark, github for +// light), reproduced as a CodeMirror HighlightStyle/EditorView.theme pair so a doc's fenced code +// (still prism-react-renderer, via @theme/CodeBlock) and this editor keep reading as the same +// family even though the two no longer share a tokenizer. +import { EditorView } from '@codemirror/view'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; + +const DARK_COLORS = { + background: '#292d3e', + foreground: '#bfc7d5', + property: '#c792ea', + string: '#c3e88d', + literal: '#f78c6c', + comment: '#697098', + punctuation: '#89ddff', +}; + +const LIGHT_COLORS = { + background: '#f6f8fa', + foreground: '#24292e', + property: '#005cc5', + string: '#032f62', + literal: '#005cc5', + comment: '#6a737d', + punctuation: '#24292e', +}; + +// Shared between both palettes: only the color values differ, not which tags map to which +// role. YAML keys can tokenize as either a bare propertyName or, depending on the grammar's own +// node naming, a `definition(propertyName)` wrapper - both are mapped so a yaml key gets the +// same color a json key does regardless of which one @codemirror/lang-yaml happens to emit. +function buildHighlightStyle(colors) { + return HighlightStyle.define([ + { tag: [tags.propertyName, tags.definition(tags.propertyName), tags.attributeName], color: colors.property }, + { tag: tags.string, color: colors.string }, + { tag: [tags.number, tags.bool, tags.null], color: colors.literal }, + { tag: tags.keyword, color: colors.property }, + { tag: tags.comment, color: colors.comment, fontStyle: 'italic' }, + { tag: tags.punctuation, color: colors.punctuation }, + ]); +} + +const DARK_HIGHLIGHT_STYLE = buildHighlightStyle(DARK_COLORS); +const LIGHT_HIGHLIGHT_STYLE = buildHighlightStyle(LIGHT_COLORS); + +// Mirrors EDITOR_PADDING/.gutter's own padding from the removed react-simple-code-editor setup, +// so line 1 still sits flush with the top of the frame instead of gaining a visible gap now that +// CodeMirror owns its own gutter/content layout. +const CONTENT_PADDING = '0.75rem'; + +function buildThemeExtension(colors, dark) { + return EditorView.theme( + { + '&': { + backgroundColor: colors.background, + color: colors.foreground, + }, + '.cm-content': { + fontFamily: 'var(--ifm-font-family-monospace)', + caretColor: colors.foreground, + padding: CONTENT_PADDING, + }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: colors.foreground, + }, + // Matches the old .gutter's own background/border-less look (styles.module.css) - CM's + // default gutter otherwise draws a visible seam against `.cm-content` above. + '.cm-gutters': { + backgroundColor: colors.background, + color: colors.comment, + border: 'none', + }, + '.cm-activeLineGutter, .cm-activeLine': { + backgroundColor: 'transparent', + }, + '.cm-tooltip': { + backgroundColor: 'var(--omp-card-background)', + border: '1px solid var(--omp-card-border-color)', + borderRadius: 'var(--omp-card-radius)', + }, + '.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': { + backgroundColor: 'var(--ifm-color-primary)', + color: '#fff', + }, + }, + { dark }, + ); +} + +const DARK_THEME_EXTENSION = buildThemeExtension(DARK_COLORS, true); +const LIGHT_THEME_EXTENSION = buildThemeExtension(LIGHT_COLORS, false); + +// One array per color mode, swapped whole into index.js's theme Compartment - keeping the +// EditorView.theme (chrome colors) and the HighlightStyle (token colors) paired together here +// means a caller only ever has to think about "dark or light", not keep two separate pieces in +// sync by hand. +export function getThemeExtensions(colorMode) { + return colorMode === 'dark' + ? [DARK_THEME_EXTENSION, syntaxHighlighting(DARK_HIGHLIGHT_STYLE)] + : [LIGHT_THEME_EXTENSION, syntaxHighlighting(LIGHT_HIGHLIGHT_STYLE)]; +} diff --git a/website/src/components/ConfigEditor/externalError.js b/website/src/components/ConfigEditor/externalError.js new file mode 100644 index 000000000..c1e93145a --- /dev/null +++ b/website/src/components/ConfigEditor/externalError.js @@ -0,0 +1,113 @@ +// Draws the caller-supplied syntax-error squiggle (errorLocation/errorMessage - see index.js's +// own prop doc comments) as a decoration, entirely separate from @codemirror/lint's diagnostic +// state (there is no schema-validation linter wired in at all - see editorExtensions.js - so +// today that state is simply empty, but the separation still matters): errorLocation is derived +// by the CALLER (Studio/index.js, Config.js) from a client-side re-parse that can catch a +// slightly different, more specific error than whatever CodeMirror-native diagnostics a future +// linter might add - piggybacking on the lint StateField would mean whichever ran last silently +// overwrote the other instead of both being able to show what they know. +import { StateEffect, StateField } from '@codemirror/state'; +import { Decoration, EditorView, hoverTooltip } from '@codemirror/view'; + +const setExternalErrorEffect = StateEffect.define(); + +// Turns a { line, column, endColumn } (all 1-based, matching errorPosition.js's own convention) +// into a document offset range, or null if the location no longer exists in the current +// document - the error came from a re-parse of a PREVIOUS value of the text (see the callers' +// own debounce), so by the time this runs the doc may already have grown/shrunk past it. +function toDocRange(doc, location) { + if (!location || location.line < 1 || location.line > doc.lines) { + return null; + } + + const line = doc.line(location.line); + const from = Math.min(line.to, Math.max(line.from, line.from + location.column - 1)); + const to = Math.min(line.to, Math.max(from, line.from + location.endColumn - 1)); + + if (to <= from) { + return null; + } + + return { from, to, message: location.message }; +} + +const errorMarkDeco = Decoration.mark({ class: 'cm-omp-external-error' }); + +// Stores the raw { line, column, endColumn, message } location the caller last supplied, not a +// pre-computed doc range - a plain keystroke elsewhere in the document changes what offset "line +// N" maps to without changing the location itself, so the range is re-derived from the CURRENT +// doc on every read (see the decorations/hover providers below) instead of tracked incrementally. +const externalErrorField = StateField.define({ + create() { + return null; + }, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setExternalErrorEffect)) { + value = effect.value; + } + } + return value; + }, + provide: (field) => + EditorView.decorations.of((view) => { + const range = toDocRange(view.state.doc, view.state.field(field)); + return range ? Decoration.set([errorMarkDeco.range(range.from, range.to)]) : Decoration.none; + }), +}); + +// Mirrors ErrorIndicator.js's own tooltip title/copy ("Config error" + the raw message) so +// hovering the squiggle and hovering the warning triangle in the actions row read as the same +// message surfaced two ways, not two different explanations of the same problem. +const externalErrorHover = hoverTooltip((view, pos) => { + const range = toDocRange(view.state.doc, view.state.field(externalErrorField, false)); + if (!range || pos < range.from || pos > range.to) { + return null; + } + + return { + pos: range.from, + end: range.to, + above: true, + create() { + const dom = document.createElement('div'); + dom.className = 'cm-omp-external-error-tooltip'; + const title = document.createElement('div'); + title.className = 'cm-omp-external-error-tooltip-title'; + title.textContent = 'Config error'; + const text = document.createElement('div'); + text.textContent = range.message; + dom.append(title, text); + return { dom }; + }, + }; +}); + +// baseTheme rather than a styles.module.css rule: this module has no CSS Module of its own, and +// a plain global class here would leak into every consumer of the site's stylesheet - baseTheme +// scopes it to CodeMirror's own generated stylesheet the same way the rest of the theme +// extensions (editorTheme.js) do. +const externalErrorBaseTheme = EditorView.baseTheme({ + '.cm-omp-external-error': { + textDecoration: 'underline wavy var(--omp-error-color, #f07178)', + textUnderlineOffset: '2px', + }, + '.cm-omp-external-error-tooltip': { + padding: '0.5rem 0.65rem', + maxWidth: '18rem', + fontSize: '0.85rem', + lineHeight: '1.4', + }, + '.cm-omp-external-error-tooltip-title': { + fontWeight: '600', + marginBottom: '0.25rem', + }, +}); + +export const externalErrorExtension = [externalErrorField, externalErrorHover, externalErrorBaseTheme]; + +// Dispatched from index.js whenever the errorLocation/errorMessage props change - `error` is +// either { line, column, endColumn, message } or null/undefined for "clear the squiggle". +export function setExternalError(view, error) { + view.dispatch({ effects: setExternalErrorEffect.of(error || null) }); +} diff --git a/website/src/components/ConfigEditor/index.js b/website/src/components/ConfigEditor/index.js index 33d8bd06a..f2aca10d7 100644 --- a/website/src/components/ConfigEditor/index.js +++ b/website/src/components/ConfigEditor/index.js @@ -1,47 +1,25 @@ -import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; -import useIsomorphicLayoutEffect from '@docusaurus/useIsomorphicLayoutEffect'; +import React, { useEffect, useId, useRef } from 'react'; import { useColorMode } from '@docusaurus/theme-common'; -import { Highlight, Prism, themes } from 'prism-react-renderer'; import classnames from 'classnames'; -import Editor from 'react-simple-code-editor'; +import { EditorState, Compartment } from '@codemirror/state'; +import { EditorView, keymap, lineNumbers } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { bracketMatching, indentOnInput } from '@codemirror/language'; +import { + acceptCompletion, + autocompletion, + closeBrackets, + closeBracketsKeymap, + completionKeymap, + completionStatus, +} from '@codemirror/autocomplete'; +import { lintKeymap } from '@codemirror/lint'; import { CONFIG_FORMATS } from '../Studio/config'; -import { getCompletions, getCompletionReplacement, getHoverInfo } from './completion'; +import { getLanguageExtensions } from './editorExtensions'; +import { getThemeExtensions } from './editorTheme'; +import { externalErrorExtension, setExternalError } from './externalError'; import styles from './styles.module.css'; -// The docs' own code fences (@theme/CodeBlock) run on prism-react-renderer too, through -// @docusaurus/theme-common's usePrismTheme() - which now resolves to this exact pair via -// docusaurus.config.js's themeConfig.prism.theme/darkTheme, so a doc's fenced code and the -// editor stay visually identical in both colour modes. -const DARK_CODE_THEME = themes.palenight; -const LIGHT_CODE_THEME = themes.github; - -// Kept equal to the gutter's own vertical padding (--omp-space-2 in styles.module.css) so -// line N's number sits on line N's code. Expressed here rather than in CSS because the -// editor's inner layers need it inline - see the padding prop below. -const EDITOR_PADDING = '0.75rem'; - -// The editor's font is monospace (.editor in styles.module.css), so every character occupies -// the same width - measuring one digit via a scratch canvas gives the per-column pixel width -// needed to turn a mouse position into a text offset (see handleEditorMouseMove), without -// creating a real DOM node just to measure. Cached per font string since the editor's -// font-size can change across the responsive breakpoint in styles.module.css. -const charWidthCache = new Map(); -function getCharWidth(target) { - const cs = getComputedStyle(target); - const font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; - const cached = charWidthCache.get(font); - if (cached) { - return cached; - } - - const canvas = getCharWidth.canvas || (getCharWidth.canvas = document.createElement('canvas')); - const ctx = canvas.getContext('2d'); - ctx.font = font; - const width = ctx.measureText('0').width; - charWidthCache.set(font, width); - return width; -} - // Keyboard behaviour for the format switch, mirroring @docusaurus/theme-classic's own Tabs // (node_modules/@docusaurus/theme-classic/lib/theme/Tabs/index.js's handleKeydown) closely // enough that a reader who knows the docs' tabs already knows how to drive this one: Enter/Space @@ -102,14 +80,44 @@ function FormatTabs({ formats, format, onFormatChange }) { ); } -// The reusable half of the studio: a highlighted, gutter-numbered, window-chromed config editor -// with a format switch, extracted so both the studio (Studio/index.js) and each segment doc's -// own editor (Config.js) get the exact same editing surface. Rendering (debounce, wasm load, -// starters, error handling) stays with each caller - see useWasmRenderer.js for the piece of -// that which *is* shared. +// Extensions every format shares, regardless of which language Compartment content is active: +// undo/redo, auto-indent on newline, bracket matching/closing, and the completion UI itself. +// autocompletion() belongs here rather than inside editorExtensions.js's per-format list - +// schemaCompletion.js's schemaCompletionSource only ever REGISTERS itself via the language's own +// `data.of({ autocomplete })` facet (see editorExtensions.js), it never installs the popup/keymap +// machinery that actually reads it - toml simply never populates that facet, so including this +// unconditionally costs it nothing. +function buildBaseExtensions() { + return [ + lineNumbers(), + history(), + indentOnInput(), + bracketMatching(), + closeBrackets(), + autocompletion(), + keymap.of([ + // completionKeymap only binds Enter for acceptance; Tab is the other accept key every + // editor (and this one, before the CodeMirror migration) teaches readers to reach for. + // acceptCompletion returns false when no completion is active, letting Tab fall through + // to its default behaviour then. + { key: 'Tab', run: acceptCompletion }, + ...closeBracketsKeymap, + ...defaultKeymap, + ...historyKeymap, + ...completionKeymap, + ...lintKeymap, + ]), + ]; +} + +// The reusable half of the studio: a CodeMirror 6 surface with schema-aware completion/linting +// (json, yaml), syntax highlighting (all three formats), and a format switch, extracted so both +// the studio (Studio/index.js) and each segment doc's own editor (Config.js) get the exact same +// editing surface. Rendering (debounce, wasm load, starters, error handling) stays with each +// caller - see useWasmRenderer.js for the piece of that which *is* shared. // // A fully controlled component: `value`/`onChange` and `format`/`onFormatChange` are owned by -// the caller, same as before this was extracted out of Studio/index.js. +// the caller, same as before this was migrated off react-simple-code-editor. function ConfigEditor({ label = 'Config', srLabel, @@ -123,7 +131,7 @@ function ConfigEditor({ // 'segment' completes against #/definitions/segment instead, for the bare-segment sample // editor every segment doc page renders (see Config.js) - without this, completion would // resolve every top-level property against the wrong schema (root config fields instead of - // segment fields). + // segment fields). See editorExtensions.js's getScopedSchema. schemaScope = 'config', // { line, column, endColumn } (all 1-based) of a syntax error to draw a squiggly underline // under, or null/undefined for none - see errorPosition.js's getSyntaxErrorLocation, which @@ -135,692 +143,164 @@ function ConfigEditor({ // errorLocation - null/undefined whenever errorLocation is, so both are always kept in sync // by the caller (see Studio/index.js and Config.js's shared `displayError` gate). errorMessage = null, - // Notified with the popup's own open/closed state, so a caller can suppress its error - // banner while it's open - the config is almost always momentarily invalid mid-completion + // Notified with the completion popup's own open/closed state, so a caller can suppress its + // error banner while it's open - the config is almost always momentarily invalid mid-completion // (an open string, a freshly chained empty object), and there is no point flagging that to - // a reader who is still actively picking a suggestion. + // a reader who is still actively picking a suggestion. 'pending' (a completion source is still + // being queried) intentionally does NOT count as open - only 'active' does - matching how the + // old hand-rolled popup only ever reported itself open once it actually had items to show. onCompletionOpenChange, }) { const { colorMode } = useColorMode(); - const codeTheme = colorMode === 'dark' ? DARK_CODE_THEME : LIGHT_CODE_THEME; - // react-simple-code-editor's inner