Merge pull request #1250 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-14 21:29:04 -04:00
committed by GitHub
11 changed files with 3444 additions and 268 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.79.1",
"version": "7.79.1-dev.2",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
+4 -1
View File
@@ -16,7 +16,10 @@ const path = require('path');
const zlib = require('zlib');
const UI_DIR = path.join(__dirname, '../dist/ui');
const MAX_SIZE = 1.5 * 1024 * 1024; // 1.5MB - reasonable for full-featured React dashboard
// 1.75MB - bumped from 1.5MB when the code editor moved from
// react-simple-code-editor + prism-react-renderer (~18KB) to CodeMirror 6
// (~80KB gzipped) to restore native selection/copy in the raw config viewers.
const MAX_SIZE = 1.75 * 1024 * 1024;
function getGzipSize(filePath) {
const content = fs.readFileSync(filePath);
+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",
+462 -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,383 @@ function validateToml(code: string): ValidationResult {
}
}
// Line-anchored key patterns for TOML/YAML. Each captures (indent, key). Keys
// may be bare, basic-string ("..."), or literal-string ('...'). JSON uses a
// dedicated state-aware scanner (`findJsonSensitiveRanges`) instead so that
// `"API_KEY":` substrings embedded inside escaped value strings are not
// mis-classified as keys.
const SENSITIVE_PATTERNS: Record<'yaml' | 'toml', RegExp> = {
yaml: /^(\s*)("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_.-]+)\s*:\s*/gm,
toml: /^(\s*)("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_.-]+)\s*=\s*/gm,
};
/**
* Strip the surrounding quotes (and unescape minimally for basic strings) from
* a key captured by the patterns above.
*/
function normalizeKey(raw: string): string {
if (raw.length >= 2 && raw[0] === '"' && raw[raw.length - 1] === '"') {
return raw.slice(1, -1).replace(/\\(.)/g, '$1');
}
if (raw.length >= 2 && raw[0] === "'" && raw[raw.length - 1] === "'") {
return raw.slice(1, -1);
}
return raw;
}
/**
* Advance past a TOML/JSON string starting at `start` (whose first char is the
* opening quote). Basic strings (") honor backslash escapes; literal strings
* (') do not.
*/
function skipString(text: string, start: number, quote: '"' | "'"): number {
let i = start + 1;
if (quote === "'") {
const idx = text.indexOf("'", i);
return idx >= 0 ? idx + 1 : text.length;
}
while (i < text.length) {
const ch = text[i];
if (ch === '\\') {
i += 2;
continue;
}
if (ch === '"') return i + 1;
i++;
}
return text.length;
}
/**
* Advance past a TOML string starting at `start`. Detects triple-quoted
* multi-line strings (""" … """ and ''' … ''') so a `]` or `#` inside the
* string body cannot terminate an enclosing array or pretend to be a comment.
*/
function skipTomlString(text: string, start: number): number {
const ch = text[start];
if (ch !== '"' && ch !== "'") return start + 1;
if (text.slice(start, start + 3) === ch + ch + ch) {
const triple = ch + ch + ch;
const closeIdx = text.indexOf(triple, start + 3);
return closeIdx >= 0 ? closeIdx + 3 : text.length;
}
return skipString(text, start, ch);
}
/**
* Locate the end offset of the value that begins at `valueStart`. Handles
* multi-line TOML strings (""" … """, ''' … '''), arrays ([ … ]), YAML block
* scalars (| / >), and JSON arrays/objects. Falls back to end-of-line.
*/
function findValueEnd(
doc: EditorView['state']['doc'],
valueStart: number,
language: 'json' | 'yaml' | 'toml',
keyIndent: number
): number {
const docLen = doc.length;
if (valueStart >= docLen) return valueStart;
const lookahead = doc.sliceString(valueStart, Math.min(valueStart + 3, docLen));
if (language === 'toml') {
if (lookahead === '"""' || lookahead === "'''") {
const delim = lookahead;
const rest = doc.sliceString(valueStart + 3, docLen);
const closeIdx = rest.indexOf(delim);
return closeIdx >= 0 ? valueStart + 3 + closeIdx + 3 : docLen;
}
if (lookahead[0] === '[') {
// Track bracket depth, skipping content inside strings and comments.
// Use the backslash-aware skipper so `\"` inside basic strings does not
// prematurely close the string scan, and skip `# ... \n` so a `]` that
// appears inside a TOML comment cannot terminate the array early.
const text = doc.toString();
let depth = 0;
let i = valueStart;
while (i < docLen) {
const ch = text[i];
if (ch === '"' || ch === "'") {
// TOML strings may be triple-quoted. Skip the whole literal as one
// unit so its body cannot terminate the array.
i = skipTomlString(text, i);
continue;
}
if (ch === '#') {
const nl = text.indexOf('\n', i);
i = nl >= 0 ? nl + 1 : docLen;
continue;
}
if (ch === '[') depth++;
else if (ch === ']') {
depth--;
if (depth === 0) return i + 1;
}
i++;
}
return docLen;
}
return doc.lineAt(valueStart).to;
}
if (language === 'yaml') {
if (lookahead[0] === '|' || lookahead[0] === '>') {
// Block scalar: include subsequent lines indented deeper than the key.
let line = doc.lineAt(valueStart);
let end = line.to;
while (line.number < doc.lines) {
const next = doc.line(line.number + 1);
const trimmed = next.text.replace(/\s+$/, '');
if (trimmed === '') {
end = next.to;
line = next;
continue;
}
const indent = next.text.search(/\S/);
if (indent <= keyIndent) break;
end = next.to;
line = next;
}
return end;
}
return doc.lineAt(valueStart).to;
}
// JSON: walk balanced braces/brackets when the value starts with one.
if (lookahead[0] === '[' || lookahead[0] === '{') {
const openCh = lookahead[0];
const closeCh = openCh === '[' ? ']' : '}';
const text = doc.toString();
let depth = 0;
let i = valueStart;
while (i < docLen) {
const ch = text[i];
if (ch === '"') {
i = skipString(text, i, '"');
continue;
}
if (ch === openCh) depth++;
else if (ch === closeCh) {
depth--;
if (depth === 0) return i + 1;
}
i++;
}
return docLen;
}
return doc.lineAt(valueStart).to;
}
/**
* JSON-specific scanner: walks the document character by character, only
* registering a quoted string as a key when it sits at an object-key position
* (preceded by `{` or `,`, ignoring whitespace) AND is followed by `:`. This
* avoids the regex false-positive where a `"API_KEY":` substring inside an
* escaped value string would be treated as a sensitive key.
*/
function findJsonSensitiveRanges(text: string): Array<{ valueStart: number; key: string }> {
const hits: Array<{ valueStart: number; key: string }> = [];
let i = 0;
let prevSignificant: string | null = null; // last non-whitespace structural char
while (i < text.length) {
const ch = text[i];
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
i++;
continue;
}
if (ch === '"') {
const stringStart = i;
const stringEnd = skipString(text, i, '"');
// Skip whitespace to peek at the next significant character.
let j = stringEnd;
while (
j < text.length &&
(text[j] === ' ' || text[j] === '\t' || text[j] === '\n' || text[j] === '\r')
) {
j++;
}
const isKeyPos =
prevSignificant === '{' || prevSignificant === ',' || prevSignificant === null;
if (isKeyPos && text[j] === ':') {
const rawKey = text.slice(stringStart + 1, stringEnd - 1);
const key = rawKey.replace(/\\(.)/g, '$1');
// Advance past the colon and any whitespace to the value start.
let valueStart = j + 1;
while (
valueStart < text.length &&
(text[valueStart] === ' ' ||
text[valueStart] === '\t' ||
text[valueStart] === '\n' ||
text[valueStart] === '\r')
) {
valueStart++;
}
hits.push({ valueStart, key });
prevSignificant = '"';
i = j + 1;
continue;
}
// Not a key position; treat the string as a value scalar.
prevSignificant = '"';
i = stringEnd;
continue;
}
prevSignificant = ch;
i++;
}
return hits;
}
function buildSensitiveDecorations(
view: EditorView,
language: 'json' | 'yaml' | 'toml'
): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const doc = view.state.doc;
// Scan the entire document (decoration ranges outside the viewport are
// dropped automatically) so a multi-line value beginning above the viewport
// still masks when its tail scrolls into view.
const text = doc.toString();
if (language === 'json') {
for (const { valueStart, key } of findJsonSensitiveRanges(text)) {
if (!isSensitiveKey(key)) continue;
if (valueStart >= doc.length) continue;
const valueEnd = findValueEnd(doc, valueStart, 'json', 0);
const trimmedLen = doc.sliceString(valueStart, valueEnd).replace(/\s+$/, '').length;
if (trimmedLen === 0) continue;
builder.add(
valueStart,
valueStart + trimmedLen,
Decoration.mark({ class: 'cm-sensitive-mask' })
);
}
return builder.finish();
}
const pattern = new RegExp(SENSITIVE_PATTERNS[language].source, 'gm');
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
const key = normalizeKey(match[2]);
const keyIndent = match[1].length;
if (!isSensitiveKey(key)) continue;
const valueStart = match.index + match[0].length;
if (valueStart >= doc.length) continue;
const valueEnd = findValueEnd(doc, valueStart, language, keyIndent);
const trimmedLen = doc.sliceString(valueStart, valueEnd).replace(/\s+$/, '').length;
if (trimmedLen === 0) continue;
builder.add(
valueStart,
valueStart + trimmedLen,
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) {
// Decorations are derived from the whole document, not the viewport,
// so a viewport-only change (scroll) does not require a rebuild.
if (update.docChanged) {
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',
},
// The reveal class is placed on the @uiw/react-codemirror wrapper rather
// than directly on `.cm-editor`, so target the wrapper as the toggle root.
'.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 +463,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 +569,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">
+2760
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
export const LOCALE_STORAGE_KEY = 'ccs-ui-locale';
export const SUPPORTED_LOCALES = ['en', 'zh-CN', 'vi', 'ja'] as const;
export const SUPPORTED_LOCALES = ['en', 'zh-CN', 'vi', 'ja', 'ko'] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
@@ -10,6 +10,7 @@ export function isSupportedLocale(locale: string): locale is AppLocale {
export function normalizeLocale(locale: string | null | undefined): AppLocale {
if (!locale) return 'en';
if (locale.toLowerCase().startsWith('ko')) return 'ko';
if (locale.toLowerCase().startsWith('ja')) return 'ja';
if (locale.toLowerCase().startsWith('zh')) return 'zh-CN';
if (locale.toLowerCase().startsWith('vi')) return 'vi';
+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(),
+123 -40
View File
@@ -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,121 @@ 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();
});
it('masks the entire multi-line TOML triple-quoted secret value', () => {
const value = [
'name = "demo"',
'API_KEY = """',
'secret-line-one',
'secret-line-two',
'"""',
'other = "visible"',
].join('\n');
const { container } = render(<CodeEditor value={value} onChange={vi.fn()} language="toml" />);
const masks = container.querySelectorAll('.cm-sensitive-mask');
expect(masks.length).toBeGreaterThan(0);
const masked = Array.from(masks)
.map((el) => el.textContent ?? '')
.join('');
expect(masked).toContain('secret-line-one');
expect(masked).toContain('secret-line-two');
expect(masked).not.toContain('visible');
});
it('masks values whose key uses a TOML quoted-key form', () => {
const value = ['"API_KEY" = "supersecret"', 'visible = "ok"'].join('\n');
const { container } = render(<CodeEditor value={value} onChange={vi.fn()} language="toml" />);
const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask'))
.map((el) => el.textContent ?? '')
.join('');
expect(masked).toContain('supersecret');
expect(masked).not.toContain('ok');
});
it('does not terminate TOML array masking on a `]` inside a triple-quoted string', () => {
const value = ['AUTH_TOKEN = [', ' """abc]xyz""",', ' "second",', ']', 'visible = "ok"'].join(
'\n'
);
const { container } = render(<CodeEditor value={value} onChange={vi.fn()} language="toml" />);
const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask'))
.map((el) => el.textContent ?? '')
.join('');
expect(masked).toContain('abc]xyz');
expect(masked).toContain('"second"');
expect(masked).not.toContain('ok');
});
it('ignores JSON pseudo-keys embedded inside escaped value strings', () => {
const value = '{"description": "use \\"API_KEY\\": header", "API_KEY": "real-secret"}';
const { container } = render(<CodeEditor value={value} onChange={vi.fn()} language="json" />);
const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask'))
.map((el) => el.textContent ?? '')
.join('');
// Only the real API_KEY value gets masked, not the embedded `API_KEY` text
// inside the `description` value.
expect(masked).toContain('real-secret');
expect(masked).not.toContain('use ');
expect(masked).not.toContain('header');
});
it('does not terminate TOML array masking on a `]` inside a comment', () => {
const value = [
'AUTH_TOKEN = [',
' "first", # closing bracket in comment: ]',
' "second",',
']',
'visible = "ok"',
].join('\n');
const { container } = render(<CodeEditor value={value} onChange={vi.fn()} language="toml" />);
const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask'))
.map((el) => el.textContent ?? '')
.join('');
expect(masked).toContain('"first"');
expect(masked).toContain('"second"');
expect(masked).not.toContain('ok');
});
it('respects escaped quotes inside TOML array values when masking', () => {
const value = ['AUTH_TOKEN = ["a \\"quoted\\" value", "x"]', 'visible = "ok"'].join('\n');
const { container } = render(<CodeEditor value={value} onChange={vi.fn()} language="toml" />);
const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask'))
.map((el) => el.textContent ?? '')
.join('');
// The closing bracket must be reached; both array members and the
// unrelated `visible` line stay on their respective sides of the mask.
expect(masked).toContain('"x"');
expect(masked).not.toContain('ok');
});
it('masks an entire multi-line TOML array value bound to a sensitive key', () => {
const value = ['AUTH_TOKEN = [', ' "first",', ' "second",', ']', 'visible = "ok"'].join('\n');
const { container } = render(<CodeEditor value={value} onChange={vi.fn()} language="toml" />);
const masked = Array.from(container.querySelectorAll('.cm-sensitive-mask'))
.map((el) => el.textContent ?? '')
.join('');
expect(masked).toContain('"first"');
expect(masked).toContain('"second"');
expect(masked).not.toContain('ok');
});
});
@@ -96,6 +96,7 @@ describe('Dashboard i18n', () => {
it.each([
{ locale: 'vi', label: 'Vietnamese', browserLocale: 'vi-VN' },
{ locale: 'ja', label: 'Japanese', browserLocale: 'ja-JP' },
{ locale: 'ko', label: 'Korean', browserLocale: 'ko-KR' },
])(
'supports $locale locale in switcher and persistence',
async ({ locale, label, browserLocale }) => {
@@ -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));