mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
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:
+53
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user