fix(ui): make codex config.toml viewer selectable

Replace the hand-rolled textarea+Prism overlay in CodeEditor with
CodeMirror 6. The overlay hid the textarea selection with
`selection:bg-transparent` and `WebkitTextFillColor: transparent` as a
workaround for wrap drift between the highlight layer and the textarea,
which left Ctrl+A / drag-select / Ctrl+C with no visible result.

CodeMirror handles selection, copy, undo, and wrap natively. Custom
themes match the previous prism-react-renderer github (light) and
nightOwl (dark) palettes; background is transparent so the surrounding
bg-muted/30 still shows; font, font-size, line-height, padding, and
gutter-less layout are preserved.

Public CodeEditor props are unchanged (value, onChange, language,
readonly, exactText, className, minHeight, heightMode) so all seven
call sites keep working. The sensitive-key blur is reimplemented as a
CodeMirror Decoration and stays gated behind the existing Eye toggle.

Test setup gets a Range.getClientRects polyfill so CM mounts cleanly in
JSDOM, and shared-page.test.tsx checks .cm-content text directly.

Closes #1247
This commit is contained in:
Tam Nhu Tran
2026-05-14 17:22:26 -04:00
parent aba15d8646
commit 420494e0e0
6 changed files with 327 additions and 265 deletions
+53
View File
@@ -5,6 +5,9 @@
"": {
"name": "ui",
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/legacy-modes": "^6.5.3",
"@hookform/resolvers": "^5.2.2",
"@nivo/core": "^0.99.0",
"@nivo/sankey": "^0.99.0",
@@ -25,6 +28,8 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-themes": "^4.25.9",
"@uiw/react-codemirror": "^4.25.9",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -131,6 +136,28 @@
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ=="],
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
"@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "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" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="],
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "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" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
"@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.3", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg=="],
"@codemirror/lint": ["@codemirror/lint@6.9.6", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A=="],
"@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="],
"@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
"@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="],
"@codemirror/view": ["@codemirror/view@6.43.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA=="],
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
@@ -253,6 +280,18 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
"@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
"@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@mswjs/interceptors": ["@mswjs/interceptors@0.40.0", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ=="],
"@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
@@ -539,6 +578,12 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.48.1", "", { "dependencies": { "@typescript-eslint/types": "8.48.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q=="],
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.9", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw=="],
"@uiw/codemirror-themes": ["@uiw/codemirror-themes@4.25.9", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-DAHKb/L9ELwjY4nCf/MP/mIllHOn4GQe7RR4x8AMJuNeh9nGRRoo1uPxrxMmUL/bKqe6kDmDbIZ2AlhlqyIJuw=="],
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.9", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.9", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.1", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.16", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.16", "ast-v8-to-istanbul": "^0.3.8", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.16", "vitest": "4.0.16" }, "optionalPeers": ["@vitest/browser"] }, "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A=="],
@@ -607,6 +652,8 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -617,6 +664,8 @@
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
@@ -1037,6 +1086,8 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
@@ -1103,6 +1154,8 @@
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webidl-conversions": ["webidl-conversions@8.0.0", "", {}, "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA=="],
+5
View File
@@ -19,6 +19,9 @@
"test:ui": "vitest --ui"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/legacy-modes": "^6.5.3",
"@hookform/resolvers": "^5.2.2",
"@nivo/core": "^0.99.0",
"@nivo/sankey": "^0.99.0",
@@ -39,6 +42,8 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3",
"@uiw/codemirror-themes": "^4.25.9",
"@uiw/react-codemirror": "^4.25.9",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+218 -220
View File
@@ -1,16 +1,19 @@
/**
* Code Editor Component
* Lightweight JSON/TOML editor with syntax highlighting, line numbers, and validation
* Uses react-simple-code-editor + prism-react-renderer for minimal bundle size (~18KB)
* CodeMirror 6 backed JSON/YAML/TOML editor with native selection, copy, undo,
* syntax highlighting, validation, and sensitive-value masking.
*/
import { useState, useCallback, useMemo, useRef, type UIEvent } from 'react';
import Editor from 'react-simple-code-editor';
import { Highlight, themes } from 'prism-react-renderer';
import Prism from 'prismjs';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-yaml';
import 'prismjs/components/prism-toml';
import { useMemo, useState } from 'react';
import CodeMirror, { EditorView, type Extension } from '@uiw/react-codemirror';
import { json } from '@codemirror/lang-json';
import { yaml } from '@codemirror/lang-yaml';
import { StreamLanguage } from '@codemirror/language';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { createTheme } from '@uiw/codemirror-themes';
import { tags as t } from '@lezer/highlight';
import { RangeSetBuilder } from '@codemirror/state';
import { Decoration, type DecorationSet, ViewPlugin, type ViewUpdate } from '@codemirror/view';
import { parse as parseToml } from 'smol-toml';
import { useTheme } from '@/hooks/use-theme';
import { cn } from '@/lib/utils';
@@ -24,6 +27,10 @@ interface CodeEditorProps {
onChange: (value: string) => void;
language?: 'json' | 'yaml' | 'toml';
readonly?: boolean;
/**
* Retained for API compatibility. CodeMirror always preserves exact text,
* so this flag is a no-op; callers can pass it without effect.
*/
exactText?: boolean;
className?: string;
minHeight?: string;
@@ -36,53 +43,31 @@ interface ValidationResult {
line?: number;
}
/**
* Validate JSON and extract error location
*/
function validateJson(code: string): ValidationResult {
if (!code.trim()) {
return { valid: true };
}
if (!code.trim()) return { valid: true };
try {
JSON.parse(code);
return { valid: true };
} catch (e) {
const error = e as SyntaxError;
const message = error.message;
// Try to extract line number from error message
// Format: "... at position X" or "... at line Y column Z"
const message = (e as SyntaxError).message;
const posMatch = message.match(/position (\d+)/);
if (posMatch) {
const pos = parseInt(posMatch[1], 10);
const lines = code.substring(0, pos).split('\n');
return {
valid: false,
error: message,
line: lines.length,
};
return { valid: false, error: message, line: lines.length };
}
return {
valid: false,
error: message,
};
return { valid: false, error: message };
}
}
function validateToml(code: string): ValidationResult {
if (!code.trim()) {
return { valid: true };
}
if (!code.trim()) return { valid: true };
try {
parseToml(code);
return { valid: true };
} catch (error) {
const message = (error as Error).message;
const lineMatch = message.match(/line\s+(\d+)/i);
return {
valid: false,
error: message,
@@ -91,12 +76,139 @@ function validateToml(code: string): ValidationResult {
}
}
const SENSITIVE_PATTERNS: Record<'json' | 'yaml' | 'toml', RegExp> = {
json: /"([^"\\]+)"\s*:\s*(?=("[^"\\]*"|true|false|null|-?\d[\d.eE+-]*))/g,
yaml: /^\s*([A-Za-z0-9_.-]+)\s*:\s*(?=\S)/gm,
toml: /^\s*([A-Za-z0-9_.-]+)\s*=\s*(?=\S)/gm,
};
function buildSensitiveDecorations(
view: EditorView,
language: 'json' | 'yaml' | 'toml'
): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const pattern = new RegExp(SENSITIVE_PATTERNS[language].source, 'gm');
for (const { from, to } of view.visibleRanges) {
const text = view.state.doc.sliceString(from, to);
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
const key = match[1];
if (!isSensitiveKey(key)) continue;
const valueStart = from + match.index + match[0].length;
const line = view.state.doc.lineAt(valueStart);
const valueEnd = line.to;
const trimmed = view.state.doc.sliceString(valueStart, valueEnd).replace(/\s+$/, '');
if (!trimmed) continue;
builder.add(
valueStart,
valueStart + trimmed.length,
Decoration.mark({ class: 'cm-sensitive-mask' })
);
}
}
return builder.finish();
}
function makeSensitivePlugin(language: 'json' | 'yaml' | 'toml'): Extension {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildSensitiveDecorations(view, language);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildSensitiveDecorations(update.view, language);
}
}
},
{ decorations: (v) => v.decorations }
);
}
// Visual parity with the previous prism-react-renderer themes. Hex values are
// copied from prism-react-renderer/themes/nightOwl.js and themes/github.js so
// the dashboard appearance does not shift with the editor swap.
//
// Background is left transparent on both themes so the surrounding
// `bg-muted/30` container shows through, matching the old Editor which only
// painted token colors over a transparent textarea.
const prismGithubTheme = createTheme({
theme: 'light',
settings: {
background: 'transparent',
foreground: '#393A34',
caret: '#393A34',
selection: 'rgba(57, 58, 52, 0.18)',
selectionMatch: 'rgba(57, 58, 52, 0.12)',
lineHighlight: 'transparent',
gutterBackground: 'transparent',
gutterForeground: '#999988',
},
styles: [
{ tag: [t.comment, t.lineComment, t.blockComment], color: '#999988', fontStyle: 'italic' },
{ tag: [t.string, t.special(t.string), t.regexp], color: '#e3116c' },
{
tag: [t.number, t.bool, t.null, t.atom, t.variableName, t.propertyName, t.url],
color: '#36acaa',
},
{ tag: [t.keyword, t.attributeName, t.modifier, t.operatorKeyword], color: '#00a4db' },
{ tag: [t.function(t.variableName), t.labelName], color: '#6f42c1' },
{ tag: [t.tagName, t.heading], color: '#00009f', fontWeight: 'bold' },
{ tag: [t.deleted, t.invalid], color: '#d73a49' },
{ tag: [t.punctuation, t.operator, t.bracket, t.brace, t.separator], color: '#393A34' },
],
});
const prismNightOwlTheme = createTheme({
theme: 'dark',
settings: {
background: 'transparent',
foreground: '#d6deeb',
caret: '#80a4c2',
selection: 'rgba(29, 59, 83, 0.99)',
selectionMatch: 'rgba(35, 77, 112, 0.6)',
lineHighlight: 'transparent',
gutterBackground: 'transparent',
gutterForeground: '#4b6479',
},
styles: [
{ tag: [t.comment, t.lineComment, t.blockComment], color: '#637777', fontStyle: 'italic' },
{ tag: [t.string, t.special(t.string), t.url], color: 'rgb(173, 219, 103)' },
{ tag: t.number, color: 'rgb(247, 140, 108)' },
{ tag: [t.bool, t.null, t.atom], color: 'rgb(255, 88, 116)' },
{ tag: [t.keyword, t.modifier, t.operatorKeyword], color: 'rgb(127, 219, 202)' },
{ tag: [t.propertyName, t.attributeName], color: 'rgb(128, 203, 196)' },
{ tag: [t.variableName, t.labelName], color: 'rgb(214, 222, 235)' },
{ tag: t.function(t.variableName), color: 'rgb(130, 170, 255)' },
{ tag: [t.tagName, t.heading], color: 'rgb(127, 219, 202)' },
{
tag: [t.bracket, t.punctuation, t.operator, t.brace, t.separator],
color: 'rgb(199, 146, 234)',
},
{ tag: [t.className, t.special(t.string)], color: 'rgb(255, 203, 139)' },
{ tag: [t.deleted, t.invalid], color: 'rgba(239, 83, 80, 0.56)' },
],
});
const sensitiveMaskTheme = EditorView.baseTheme({
'.cm-sensitive-mask': {
filter: 'blur(3px)',
opacity: '0.7',
transition: 'filter 200ms ease, opacity 200ms ease',
},
'.cm-editor.cm-sensitive-revealed .cm-sensitive-mask': {
filter: 'none',
opacity: '1',
},
});
export function CodeEditor({
value,
onChange,
language = 'json',
readonly = false,
exactText = false,
exactText: _exactText = false,
className,
minHeight = '300px',
heightMode = 'content',
@@ -107,210 +219,97 @@ export function CodeEditor({
const [isMasked, setIsMasked] = useState(true);
const isFillParent = heightMode === 'fill-parent';
// Validate on every change for JSON
const validation = useMemo(() => {
if (language === 'json') {
return validateJson(value);
}
if (language === 'toml') {
return validateToml(value);
}
const validation = useMemo<ValidationResult>(() => {
if (language === 'json') return validateJson(value);
if (language === 'toml') return validateToml(value);
return { valid: true };
}, [value, language]);
// Highlight function using prism-react-renderer
// Note: Line numbers removed - they break textarea/pre alignment in react-simple-code-editor
const highlightCode = useCallback(
(code: string) => (
<Highlight
prism={Prism}
theme={isDark ? themes.nightOwl : themes.github}
code={code}
language={language}
>
{({ tokens, getLineProps, getTokenProps }) => {
let nextValueIsSensitive = false;
return (
<>
{tokens.map((line, i) => (
<div
key={i}
{...getLineProps({ line })}
className={cn(validation.line === i + 1 && 'bg-destructive/20')}
>
{line.map((token, key) => {
let isSensitive = false;
// Check for sensitive keys
if (token.types.includes('property')) {
const content = token.content.replace(/['"]/g, '');
// Use shared sensitive key detection utility
if (isSensitiveKey(content)) {
nextValueIsSensitive = true;
} else {
nextValueIsSensitive = false;
}
}
// Apply masking to values following sensitive keys
else if (
(token.types.includes('string') ||
token.types.includes('number') ||
token.types.includes('boolean')) &&
nextValueIsSensitive
) {
isSensitive = true;
// Consumes the flag for this value
nextValueIsSensitive = false;
}
// Reset flag on commas or new keys (handled by property check),
// but persist through colons and whitespace
else if (token.types.includes('punctuation')) {
if (token.content !== ':' && token.content !== '[' && token.content !== '{') {
nextValueIsSensitive = false;
}
}
const tokenProps = getTokenProps({ token });
// Prism TOML emits a `table` class, which collides with Tailwind's layout utility.
tokenProps.style = {
...tokenProps.style,
display: 'inline',
};
if (isSensitive && isMasked) {
tokenProps.className = cn(
tokenProps.className,
'blur-[3px] select-none opacity-70 transition-all duration-200'
);
}
return <span key={key} {...tokenProps} />;
})}
</div>
))}
</>
);
}}
</Highlight>
),
[isDark, language, validation.line, isMasked]
);
const highlightLayerRef = useRef<HTMLDivElement>(null);
const syncHighlightScroll = useCallback((event: UIEvent<HTMLTextAreaElement>) => {
const layer = highlightLayerRef.current;
if (!layer) return;
const { scrollTop } = event.currentTarget;
layer.style.transform = `translateY(${-scrollTop}px)`;
}, []);
const extensions = useMemo<Extension[]>(() => {
const langExt =
language === 'json' ? json() : language === 'yaml' ? yaml() : StreamLanguage.define(toml);
return [
langExt,
EditorView.lineWrapping,
sensitiveMaskTheme,
makeSensitivePlugin(language),
EditorView.theme({
// Match the previous Prism overlay editor: 0.875rem mono, 1.625
// line-height, 12px padding, transparent background so the parent
// `bg-muted/30` shows through.
'&': {
fontSize: '0.875rem',
backgroundColor: 'transparent',
},
'&.cm-focused': { outline: 'none' },
'.cm-content': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
padding: '12px 0',
caretColor: 'currentColor',
},
'.cm-line': {
padding: '0 12px',
lineHeight: '1.625',
},
'.cm-scroller': {
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
lineHeight: '1.625',
},
'.cm-gutters': { display: 'none' },
}),
];
}, [language]);
return (
<div
className={cn('flex min-h-0 flex-col', isFillParent && 'h-full', className)}
style={isFillParent ? { height: minHeight === 'auto' ? undefined : minHeight } : undefined}
data-slot="code-editor-root"
>
{/* Editor container */}
<div
className={cn(
'relative rounded-md border overflow-hidden',
'bg-muted/30',
'relative rounded-md border overflow-hidden bg-muted/30',
isFillParent && 'flex min-h-0 flex-1 flex-col',
isFocused && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
readonly && 'opacity-70 cursor-not-allowed',
readonly && 'opacity-70',
!validation.valid && 'border-destructive'
)}
data-slot="code-editor-surface"
>
<div
className={cn(
isFillParent && 'scrollbar-editor min-h-0 flex-1',
isFillParent && (exactText ? 'overflow-hidden' : 'overflow-auto')
isFillParent ? 'scrollbar-editor min-h-0 flex-1 overflow-hidden' : undefined
)}
data-slot={isFillParent ? 'code-editor-viewport' : undefined}
>
{exactText ? (
// Exact mode keeps the native textarea as the editable/copyable source of truth while
// rendering Prism colors behind it with the same soft-wrap layout contract.
<div
className={cn(
'relative w-full overflow-hidden',
isFillParent ? 'h-full min-h-full' : 'min-h-[300px]'
)}
style={{
minHeight,
}}
data-slot="code-editor-exact-highlight-wrapper"
>
<div
ref={highlightLayerRef}
aria-hidden="true"
className={cn(
'pointer-events-none absolute inset-x-0 top-0 w-full p-3 font-mono text-sm leading-relaxed',
'whitespace-pre-wrap overflow-visible break-words'
)}
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '0.875rem',
minHeight,
tabSize: 2,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
data-slot="code-editor-highlight-layer"
>
{highlightCode(value)}
</div>
<textarea
value={value}
onChange={(event) => {
if (!readonly) onChange(event.target.value);
}}
readOnly={readonly}
wrap="soft"
spellCheck={false}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onScroll={syncHighlightScroll}
className={cn(
'absolute inset-0 z-10 block h-full w-full resize-none border-0 bg-transparent p-3 font-mono text-sm leading-relaxed',
'whitespace-pre-wrap overflow-y-auto overflow-x-hidden break-words text-transparent caret-foreground selection:bg-transparent focus:outline-none',
readonly && 'cursor-not-allowed'
)}
style={{
minHeight,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '0.875rem',
tabSize: 2,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
WebkitTextFillColor: 'transparent',
}}
data-slot="code-editor-plain-textarea"
/>
</div>
) : (
<Editor
value={value}
onValueChange={readonly ? () => {} : onChange}
highlight={highlightCode}
key={isDark ? 'dark-editor' : 'light-editor'}
padding={12}
disabled={readonly}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
textareaClassName={cn(
'focus:outline-none font-mono text-sm',
readonly && 'cursor-not-allowed'
)}
preClassName="font-mono text-sm"
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '0.875rem',
minHeight,
}}
/>
)}
<CodeMirror
value={value}
onChange={readonly ? undefined : onChange}
readOnly={readonly}
editable={!readonly}
theme={isDark ? prismNightOwlTheme : prismGithubTheme}
extensions={extensions}
height={isFillParent ? '100%' : undefined}
minHeight={isFillParent ? undefined : minHeight}
basicSetup={{
// Keep visual parity with the previous Prism overlay editor:
// no gutter, no line numbers, no active-line tint.
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
autocompletion: false,
searchKeymap: true,
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
className={cn(
'cm-editor-root',
!isMasked && 'cm-sensitive-revealed',
isFillParent && 'h-full'
)}
data-slot="code-editor-codemirror"
/>
</div>
<div className="absolute top-2 right-2 z-20 opacity-50 hover:opacity-100 transition-opacity">
@@ -326,7 +325,6 @@ export function CodeEditor({
</div>
</div>
{/* Validation status */}
<div className="flex items-center gap-2 mt-2 text-xs">
{validation.valid ? (
<span className="flex items-center gap-1 text-muted-foreground">
+29
View File
@@ -38,6 +38,35 @@ class ResizeObserverMock {
}
global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
// CodeMirror uses Range.getClientRects() for cursor measurement, which JSDOM
// does not implement. Polyfill it with empty rects so the editor mounts cleanly.
if (typeof Range !== 'undefined') {
Range.prototype.getClientRects =
Range.prototype.getClientRects ||
function getClientRects() {
return {
item: () => null,
length: 0,
[Symbol.iterator]: function* () {},
} as unknown as DOMRectList;
};
Range.prototype.getBoundingClientRect =
Range.prototype.getBoundingClientRect ||
function getBoundingClientRect() {
return {
x: 0,
y: 0,
top: 0,
left: 0,
bottom: 0,
right: 0,
width: 0,
height: 0,
toJSON: () => ({}),
} as DOMRect;
};
}
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@tests/setup/test-utils';
import { render, screen } from '@tests/setup/test-utils';
import { CodeEditor } from '@/components/shared/code-editor';
@@ -20,14 +20,14 @@ describe('CodeEditor', () => {
);
const viewport = container.querySelector('[data-slot="code-editor-viewport"]');
const root = container.firstElementChild;
const root = container.querySelector('[data-slot="code-editor-root"]');
expect(viewport).toBeInTheDocument();
expect(root).toHaveStyle({ height: '100%' });
expect(viewport).not.toContainElement(screen.getByText('Valid JSON'));
});
it('keeps readonly status outside the scroll viewport for bounded editors', () => {
it('renders a CodeMirror surface when readonly so the user can still select and copy', () => {
const { container } = render(
<CodeEditor
value={'{\n "provider": "openrouter"\n}'}
@@ -40,11 +40,11 @@ describe('CodeEditor', () => {
);
const viewport = container.querySelector('[data-slot="code-editor-viewport"]');
const textarea = container.querySelector('textarea');
const root = container.firstElementChild;
const root = container.querySelector('[data-slot="code-editor-root"]');
const cm = container.querySelector('[data-slot="code-editor-codemirror"]');
expect(root).toHaveStyle({ height: 'calc(60vh - 120px)' });
expect(textarea).toBeDisabled();
expect(cm).toBeInTheDocument();
expect(viewport).not.toContainElement(screen.getByText('(Read-only)'));
});
@@ -68,14 +68,11 @@ describe('CodeEditor', () => {
expect(screen.getByText('Valid TOML')).toBeInTheDocument();
});
it('renders raw TOML with wrapping syntax color while copied text stays raw', () => {
it('mounts a CodeMirror editor for TOML with the value as the document', () => {
const rawToml =
'model = "gpt-5.4"\n' +
'[agents.brainstormer]\n' +
'config_file = "agents/brainstormer.toml"\n' +
'[mcp_servers.playwright]\n' +
'command = "node"\n' +
'args = ["--experimental-loader", "./very/long/path/that/should/not/soft/wrap/into/fake/toml/lines.js"]\n';
'config_file = "agents/brainstormer.toml"\n';
const { container } = render(
<CodeEditor
@@ -88,35 +85,16 @@ describe('CodeEditor', () => {
/>
);
const textarea = container.querySelector('[data-slot="code-editor-plain-textarea"]');
const highlightLayer = container.querySelector('[data-slot="code-editor-highlight-layer"]');
const highlightedToken = highlightLayer?.querySelector('span');
const tableToken = Array.from(highlightLayer?.querySelectorAll('span') ?? []).find(
(token) => token.textContent === 'agents.brainstormer'
);
expect(textarea).toBeInstanceOf(HTMLTextAreaElement);
expect(textarea).toHaveValue(rawToml);
expect(textarea).toHaveAttribute('wrap', 'soft');
expect(textarea).toHaveClass('text-transparent');
expect(textarea).toHaveClass('whitespace-pre-wrap');
expect(textarea).toHaveClass('overflow-x-hidden');
expect(highlightLayer).toBeInTheDocument();
expect(highlightLayer).toHaveClass('whitespace-pre-wrap');
expect(highlightLayer).toHaveStyle({
overflowWrap: 'anywhere',
wordBreak: 'break-word',
});
expect(highlightedToken).toBeInTheDocument();
expect(tableToken).toHaveClass('table');
expect(tableToken).toHaveStyle({ display: 'inline' });
expect(container.querySelector('pre')).not.toBeInTheDocument();
if (textarea instanceof HTMLTextAreaElement && highlightLayer instanceof HTMLElement) {
textarea.scrollLeft = 96;
textarea.scrollTop = 24;
fireEvent.scroll(textarea);
expect(highlightLayer.style.transform).toBe('translateY(-24px)');
}
const cm = container.querySelector('[data-slot="code-editor-codemirror"]');
expect(cm).toBeInTheDocument();
const cmContent = container.querySelector('.cm-content');
expect(cmContent?.textContent).toContain('model = "gpt-5.4"');
expect(cmContent?.textContent).toContain('[agents.brainstormer]');
expect(screen.getByText('Valid TOML')).toBeInTheDocument();
});
it('reports a validation error when JSON is malformed', () => {
render(<CodeEditor value={'{ "broken": '} onChange={vi.fn()} language="json" />);
expect(screen.queryByText('Valid JSON')).not.toBeInTheDocument();
});
});
@@ -256,11 +256,10 @@ describe('SharedPage', () => {
await userEvent.click(screen.getByRole('tab', { name: /Settings/ }));
expect(await screen.findByText('Showing 1 of 1 settings')).toBeInTheDocument();
expect(
await screen.findByText((content) =>
content.includes('"ANTHROPIC_MODEL": "claude-sonnet-4-5"')
)
).toBeInTheDocument();
await waitFor(() => {
const cmContent = document.querySelector('.cm-content');
expect(cmContent?.textContent ?? '').toContain('"ANTHROPIC_MODEL": "claude-sonnet-4-5"');
});
expect(screen.getByText('/tmp/.ccs/shared/settings.json')).toBeInTheDocument();
const requestedUrls = fetchMock.mock.calls.map(([input]) => requestUrl(input));