From 420494e0e01f7f0c02266e5ed20c3abd84db29fe Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 14 May 2026 17:22:26 -0400 Subject: [PATCH] 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 --- ui/bun.lock | 53 +++ ui/package.json | 5 + ui/src/components/shared/code-editor.tsx | 438 +++++++++--------- ui/tests/setup/vitest-setup.ts | 29 ++ .../unit/components/ui/code-editor.test.tsx | 58 +-- .../unit/ui/pages/shared/shared-page.test.tsx | 9 +- 6 files changed, 327 insertions(+), 265 deletions(-) diff --git a/ui/bun.lock b/ui/bun.lock index cd5c1000..4f12c04c 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -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=="], diff --git a/ui/package.json b/ui/package.json index 872ab80e..6f2a61f7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx index 2f028edc..4c20261b 100644 --- a/ui/src/components/shared/code-editor.tsx +++ b/ui/src/components/shared/code-editor.tsx @@ -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(); + 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(() => { + 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) => ( - - {({ tokens, getLineProps, getTokenProps }) => { - let nextValueIsSensitive = false; - - return ( - <> - {tokens.map((line, i) => ( -
- {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 ; - })} -
- ))} - - ); - }} -
- ), - [isDark, language, validation.line, isMasked] - ); - const highlightLayerRef = useRef(null); - const syncHighlightScroll = useCallback((event: UIEvent) => { - const layer = highlightLayerRef.current; - if (!layer) return; - - const { scrollTop } = event.currentTarget; - layer.style.transform = `translateY(${-scrollTop}px)`; - }, []); + const extensions = useMemo(() => { + 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 (
- {/* Editor container */}
- {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. -
- -