From af34130379e5c17f91240ee96d423f1c91513553 Mon Sep 17 00:00:00 2001 From: lmk123 <5035625+lmk123@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:15:58 +0800 Subject: [PATCH] feat(logs): render request/response bodies with CodeMirror The log-trace dialog showed request/response bodies as a raw `
` string, which is hard to read for large JSON payloads. Replace the `` in `BodyBlock` with a new read-only `JsonView` component built on CodeMirror, giving syntax highlighting, code folding, line numbers, and Cmd/Ctrl-F search. The viewer is read-only but stays focusable so search and text selection work. Its theme follows the app's light/dark preference and blends into the surrounding muted panel. Non-JSON bodies (SSE streams, bodies truncated to invalid JSON) fall back to a plain-text mode that keeps search/scroll/wrap. Each body block gets a copy-to-clipboard button reusing the existing clipboard + toast helpers. All three call sites (client request, upstream request, upstream response) are covered by the single BodyBlock change. Adds @uiw/react-codemirror, @codemirror/lang-json and @uiw/codemirror-theme-github as dev dependencies (renderer-only, bundled by Vite). New i18n keys: toast.bodyCopied and logs.detail.copyBody. Co-Authored-By: Claude Opus 4.8 (1M context)--- package.json | 3 + pnpm-lock.yaml | 228 ++++++++++++++++++ src/renderer/src/components/json-view.tsx | 53 ++++ .../src/components/log-trace-dialog.tsx | 54 ++++- src/renderer/src/i18n/en.ts | 2 + src/renderer/src/i18n/zh.ts | 2 + 6 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/json-view.tsx diff --git a/package.json b/package.json index 6e0c1b4..7d8a8ff 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@base-ui/react": "^1.6.0", + "@codemirror/lang-json": "^6.0.2", "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.1.0", "@electron-toolkit/tsconfig": "^2.0.0", @@ -58,6 +59,8 @@ "@types/node": "^22.19.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@uiw/codemirror-theme-github": "^4.25.11", + "@uiw/react-codemirror": "^4.25.11", "@vitejs/plugin-react": "^5.1.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c72ea0..4421e82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@base-ui/react': specifier: ^1.6.0 version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@codemirror/lang-json': + specifier: ^6.0.2 + version: 6.0.2 '@electron-toolkit/eslint-config-prettier': specifier: ^3.0.0 version: 3.0.0(eslint@9.39.4(jiti@2.7.0))(prettier@3.9.5) @@ -48,6 +51,12 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) + '@uiw/codemirror-theme-github': + specifier: ^4.25.11 + version: 4.25.11(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6) + '@uiw/react-codemirror': + specifier: ^4.25.11 + version: 4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.6)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@vitejs/plugin-react': specifier: ^5.1.1 version: 5.2.0(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)) @@ -295,6 +304,33 @@ packages: '@types/react': optional: true + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.1': + resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.43.6': + resolution: {integrity: sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==} + '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -786,6 +822,18 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} @@ -794,6 +842,9 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -1210,6 +1261,38 @@ packages: resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@uiw/codemirror-extensions-basic-setup@4.25.11': + resolution: {integrity: sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==} + peerDependencies: + '@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' + + '@uiw/codemirror-theme-github@4.25.11': + resolution: {integrity: sha512-3s0LK3gX2mvGI996z3G0tEHZqshbeJRly+QMRsKGAI1Tfr1V475bfaW9NvnoDdINuQHt+RB+ri6q57+WWF8d+A==} + + '@uiw/codemirror-themes@4.25.11': + resolution: {integrity: sha512-SBNCOgRsCtewGNocRbmjbCkltGXlFcPJsvhxQ351VynQjnWUiPbUrFcEU/haQ3HanROdAAjWXZJPk5bMBxl2jw==} + peerDependencies: + '@codemirror/language': '>=6.0.0' + '@codemirror/state': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + + '@uiw/react-codemirror@4.25.11': + resolution: {integrity: sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==} + peerDependencies: + '@babel/runtime': '>=7.11.0' + '@codemirror/state': '>=6.0.0' + '@codemirror/theme-one-dark': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + codemirror: '>=6.0.0' + react: '>=17.0.0' + react-dom: '>=17.0.0' + '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1505,6 +1588,9 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1582,6 +1668,9 @@ packages: typescript: optional: true + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -3462,6 +3551,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + sumchecker@3.0.1: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} @@ -3697,6 +3789,9 @@ packages: yaml: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + webcrypto-core@1.9.2: resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} @@ -4037,6 +4132,64 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.10.4': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + '@lezer/common': 1.5.2 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.4 + '@lezer/json': 1.0.3 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + crelt: 1.0.7 + + '@codemirror/search@6.7.1': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + crelt: 1.0.7 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.43.6': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@dotenvx/dotenvx@1.75.1': dependencies: '@dotenvx/primitives': 0.8.0 @@ -4447,6 +4600,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 @@ -4460,6 +4629,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@marijn/find-cluster-break@1.0.3': {} + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.29) @@ -4845,6 +5016,47 @@ snapshots: '@typescript-eslint/types': 8.63.0 eslint-visitor-keys: 5.0.1 + '@uiw/codemirror-extensions-basic-setup@4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + + '@uiw/codemirror-theme-github@4.25.11(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)': + dependencies: + '@uiw/codemirror-themes': 4.25.11(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-themes@4.25.11(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6)': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + + '@uiw/react-codemirror@4.25.11(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.6)(codemirror@6.0.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@codemirror/commands': 6.10.4 + '@codemirror/state': 6.7.1 + '@codemirror/theme-one-dark': 6.1.3 + '@codemirror/view': 6.43.6 + '@uiw/codemirror-extensions-basic-setup': 4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.10.4)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/view@6.43.6) + codemirror: 6.0.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - '@codemirror/autocomplete' + - '@codemirror/language' + - '@codemirror/lint' + - '@codemirror/search' + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: '@babel/core': 7.29.7 @@ -5205,6 +5417,16 @@ snapshots: code-block-writer@13.0.3: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.10.4 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5269,6 +5491,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crelt@1.0.7: {} + cross-dirname@0.1.0: optional: true @@ -7439,6 +7663,8 @@ snapshots: strip-json-comments@3.1.1: {} + style-mod@4.1.3: {} + sumchecker@3.0.1: dependencies: debug: 4.4.3 @@ -7653,6 +7879,8 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 + w3c-keyname@2.2.8: {} + webcrypto-core@1.9.2: dependencies: '@peculiar/asn1-schema': 2.8.0 diff --git a/src/renderer/src/components/json-view.tsx b/src/renderer/src/components/json-view.tsx new file mode 100644 index 0000000..e1fc6cc --- /dev/null +++ b/src/renderer/src/components/json-view.tsx @@ -0,0 +1,53 @@ +import CodeMirror, { EditorView, type BasicSetupOptions } from '@uiw/react-codemirror' +import { json } from '@codemirror/lang-json' +import { githubDarkInit, githubLightInit } from '@uiw/codemirror-theme-github' +import { useTheme } from 'next-themes' + +/** Blend the editor into the surrounding `bg-muted` panel and match the app's `text-xs` code size. */ +const themeSettings = { + background: 'transparent', + gutterBackground: 'transparent', + fontSize: '12px' +} as const + +const lightTheme = githubLightInit({ settings: themeSettings }) +const darkTheme = githubDarkInit({ settings: themeSettings }) + +// Module-level so their identity is stable across renders (a new array would force a reconfigure). +const jsonExtensions = [json(), EditorView.lineWrapping] +const textExtensions = [EditorView.lineWrapping] + +/** A viewer, not an editor: line numbers + folding, no active-line highlight or autocompletion. */ +const readOnlySetup: BasicSetupOptions = { + lineNumbers: true, + foldGutter: true, + highlightActiveLine: false, + highlightActiveLineGutter: false, + autocompletion: false +} + +/** + * Read-only CodeMirror viewer for request/response bodies. `json` mode gives syntax + * highlighting + folding; `text` mode (SSE streams, truncated non-JSON) is plain text but + * keeps search/scroll/wrap. Theme follows the app's light/dark preference. Height is capped + * at 20rem (matching the old ` `), overflowing to an internal scroll. + */ +export function JsonView({ + value, + language = 'json' +}: { + value: string + language?: 'json' | 'text' +}): React.JSX.Element { + const { resolvedTheme } = useTheme() + return ( ++ ) +} diff --git a/src/renderer/src/components/log-trace-dialog.tsx b/src/renderer/src/components/log-trace-dialog.tsx index a781086..7a16dc9 100644 --- a/src/renderer/src/components/log-trace-dialog.tsx +++ b/src/renderer/src/components/log-trace-dialog.tsx @@ -1,5 +1,11 @@ +import { useState } from 'react' +import { toast } from 'sonner' +import { CheckIcon, CopyIcon } from 'lucide-react' import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { JsonView } from '@/components/json-view' +import { copyText } from '@/lib/clipboard' import { cn } from '@/lib/utils' import { clientLabel, @@ -24,14 +30,17 @@ interface LogTraceDialogProps { onClose: () => void } -/** Pretty-print a JSON body; fall back to the raw string (e.g. an SSE stream) when it isn't JSON. */ -function prettyBody(raw: string): string { +/** + * Pretty-print a JSON body; fall back to the raw string (e.g. an SSE stream, or a body + * truncated to invalid JSON) when it isn't parseable. `isJson` drives the viewer's language. + */ +function formatBody(raw: string): { text: string; isJson: boolean } { const trimmed = raw.trim() - if (!trimmed) return '' + if (!trimmed) return { text: '', isJson: false } try { - return JSON.stringify(JSON.parse(trimmed), null, 2) + return { text: JSON.stringify(JSON.parse(trimmed), null, 2), isJson: true } } catch { - return raw + return { text: raw, isJson: false } } } @@ -77,15 +86,42 @@ function HeadersTable({ ) } +/** A copy-to-clipboard button pinned to the top-right of a body block. */ +function CopyButton({ text, t }: { text: string; t: TFunction }): React.JSX.Element { + const [copied, setCopied] = useState(false) + const onCopy = async (): Promise => { + if (await copyText(text)) { + setCopied(true) + toast.success(t('toast.bodyCopied')) + window.setTimeout(() => setCopied(false), 1500) + } else { + toast.error(t('toast.copyFailed')) + } + } + return ( + + ) +} + function BodyBlock({ body, t }: { body: string; t: TFunction }): React.JSX.Element { - const text = prettyBody(body) + const { text, isJson } = formatBody(body) if (!text) { return {t('logs.detail.emptyBody')}
} return ( -- {text} -++) } diff --git a/src/renderer/src/i18n/en.ts b/src/renderer/src/i18n/en.ts index d617a59..b336c82 100644 --- a/src/renderer/src/i18n/en.ts +++ b/src/renderer/src/i18n/en.ts @@ -24,6 +24,7 @@ const en = { saveSuccess: 'Configuration saved', baseUrlCopied: 'Base URL copied', copyFailed: 'Copy failed', + bodyCopied: 'Body copied', apiKeyCopied: 'API key copied', apiKeyRegenerated: 'New API key generated', apiKeyRegeneratedReapplied: 'New key generated and re-applied to {count} client(s)', @@ -211,6 +212,7 @@ const en = { url: 'URL', attempt: 'Attempt {n}', emptyBody: '(empty)', + copyBody: 'Copy', truncated: 'Response body truncated to the capture size limit.', headersRedacted: 'Authorization / API-key headers are redacted.', strippedUsage: 'Usage stripped from the client-visible stream.', diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index a26968c..f8148d6 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -22,6 +22,7 @@ const zh: Dict = { saveSuccess: '配置已保存', baseUrlCopied: '网关地址已复制', copyFailed: '复制失败', + bodyCopied: '已复制内容', apiKeyCopied: 'API Key 已复制', apiKeyRegenerated: '已生成新的 API Key', apiKeyRegeneratedReapplied: '已生成新 Key 并重新接入 {count} 个客户端', @@ -207,6 +208,7 @@ const zh: Dict = { url: 'URL', attempt: '尝试 {n}', emptyBody: '(空)', + copyBody: '复制', truncated: '响应体已按抓取大小上限截断。', headersRedacted: 'Authorization / API-key 等敏感标头已脱敏。', strippedUsage: '已从客户端可见的流中剥离 usage 信息。',+ +