Просмотр исходного кода

feat(diff-editor): replace MonacoDiffEditor with SourceDiffEditor and integrate diff library for improved line diffing functionality

- Added SourceDiffEditor component to handle line diffs using react-diff-viewer-continued.
- Updated AiChangeComparePanel to utilize the new SourceDiffEditor.
- Introduced utility function prepareDiffText to normalize input for diffing.
- Enhanced tests to cover new diffing behavior and ensure leading indentation is not counted as a change.
- Removed MonacoDiffEditor and its associated tests.
darknessomi 2 месяцев назад
Родитель
Сommit
268a1c04aa

+ 391 - 3
package-lock.json

@@ -28,6 +28,7 @@
         "@tauri-apps/plugin-updater": "^2.10.1",
         "class-variance-authority": "^0.7.1",
         "clsx": "^2.1.1",
+        "diff": "^9.0.0",
         "graphology": "^0.26.0",
         "graphology-communities-louvain": "^2.0.2",
         "graphology-layout-forceatlas2": "^0.10.1",
@@ -41,6 +42,7 @@
         "pangu": "^9.1.0",
         "pinyin-pro": "^3.29.1",
         "react": "^19.2.8",
+        "react-diff-viewer-continued": "^4.4.0",
         "react-dom": "^19.2.8",
         "react-i18next": "^17.0.11",
         "react-markdown": "^10.1.0",
@@ -57,6 +59,7 @@
       },
       "devDependencies": {
         "@tauri-apps/cli": "^2.11.4",
+        "@types/diff": "^7.0.2",
         "@types/js-yaml": "^4.0.9",
         "@types/node": "^24.13.3",
         "@types/react": "^19.2.18",
@@ -1428,6 +1431,172 @@
       "integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==",
       "license": "BSD-3-Clause"
     },
+    "node_modules/@emotion/babel-plugin": {
+      "version": "11.13.5",
+      "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
+      "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.16.7",
+        "@babel/runtime": "^7.18.3",
+        "@emotion/hash": "^0.9.2",
+        "@emotion/memoize": "^0.9.0",
+        "@emotion/serialize": "^1.3.3",
+        "babel-plugin-macros": "^3.1.0",
+        "convert-source-map": "^1.5.0",
+        "escape-string-regexp": "^4.0.0",
+        "find-root": "^1.1.0",
+        "source-map": "^0.5.7",
+        "stylis": "4.2.0"
+      }
+    },
+    "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+      "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@emotion/babel-plugin/node_modules/source-map": {
+      "version": "0.5.7",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+      "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/@emotion/babel-plugin/node_modules/stylis": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
+      "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/cache": {
+      "version": "11.14.0",
+      "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
+      "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
+      "license": "MIT",
+      "dependencies": {
+        "@emotion/memoize": "^0.9.0",
+        "@emotion/sheet": "^1.4.0",
+        "@emotion/utils": "^1.4.2",
+        "@emotion/weak-memoize": "^0.4.0",
+        "stylis": "4.2.0"
+      }
+    },
+    "node_modules/@emotion/cache/node_modules/stylis": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
+      "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/css": {
+      "version": "11.13.5",
+      "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz",
+      "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==",
+      "license": "MIT",
+      "dependencies": {
+        "@emotion/babel-plugin": "^11.13.5",
+        "@emotion/cache": "^11.13.5",
+        "@emotion/serialize": "^1.3.3",
+        "@emotion/sheet": "^1.4.0",
+        "@emotion/utils": "^1.4.2"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.9.2",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
+      "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/memoize": {
+      "version": "0.9.0",
+      "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
+      "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/react": {
+      "version": "11.14.0",
+      "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
+      "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.18.3",
+        "@emotion/babel-plugin": "^11.13.5",
+        "@emotion/cache": "^11.14.0",
+        "@emotion/serialize": "^1.3.3",
+        "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
+        "@emotion/utils": "^1.4.2",
+        "@emotion/weak-memoize": "^0.4.0",
+        "hoist-non-react-statics": "^3.3.1"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@emotion/serialize": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
+      "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
+      "license": "MIT",
+      "dependencies": {
+        "@emotion/hash": "^0.9.2",
+        "@emotion/memoize": "^0.9.0",
+        "@emotion/unitless": "^0.10.0",
+        "@emotion/utils": "^1.4.2",
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/@emotion/sheet": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
+      "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/unitless": {
+      "version": "0.10.0",
+      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
+      "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/use-insertion-effect-with-fallbacks": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
+      "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@emotion/utils": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
+      "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
+      "license": "MIT"
+    },
+    "node_modules/@emotion/weak-memoize": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
+      "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
+      "license": "MIT"
+    },
     "node_modules/@exodus/bytes": {
       "version": "1.15.1",
       "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
@@ -3399,6 +3568,13 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/diff": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz",
+      "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/estree": {
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3482,6 +3658,18 @@
         "undici-types": "~7.18.0"
       }
     },
+    "node_modules/@types/parse-json": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
+      "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
+      "license": "MIT"
+    },
+    "node_modules/@types/prismjs": {
+      "version": "1.26.6",
+      "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
+      "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
+      "license": "MIT"
+    },
     "node_modules/@types/react": {
       "version": "19.2.18",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -3898,6 +4086,46 @@
         "node": ">=10.12.0"
       }
     },
+    "node_modules/babel-plugin-macros": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+      "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.12.5",
+        "cosmiconfig": "^7.0.0",
+        "resolve": "^1.19.0"
+      },
+      "engines": {
+        "node": ">=10",
+        "npm": ">=6"
+      }
+    },
+    "node_modules/babel-plugin-macros/node_modules/cosmiconfig": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
+      "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/parse-json": "^4.0.0",
+        "import-fresh": "^3.2.1",
+        "parse-json": "^5.0.0",
+        "path-type": "^4.0.0",
+        "yaml": "^1.10.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/babel-plugin-macros/node_modules/yaml": {
+      "version": "1.10.3",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
+      "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/bail": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -4215,6 +4443,12 @@
         "url": "https://polar.sh/cva"
       }
     },
+    "node_modules/classnames": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
+      "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
+      "license": "MIT"
+    },
     "node_modules/cli-cursor": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -5214,9 +5448,9 @@
       }
     },
     "node_modules/diff": {
-      "version": "8.0.4",
-      "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
-      "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
+      "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
       "license": "BSD-3-Clause",
       "engines": {
         "node": ">=0.3.1"
@@ -5742,6 +5976,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/find-root": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
+      "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
+      "license": "MIT"
+    },
     "node_modules/find-up": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
@@ -6221,6 +6461,15 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/hoist-non-react-statics": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+      "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "react-is": "^16.7.0"
+      }
+    },
     "node_modules/hono": {
       "version": "4.13.2",
       "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz",
@@ -6435,6 +6684,21 @@
       "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
       "license": "MIT"
     },
+    "node_modules/is-core-module": {
+      "version": "2.16.2",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+      "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/is-decimal": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
@@ -7517,6 +7781,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+      "license": "MIT"
+    },
     "node_modules/merge-descriptors": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
@@ -8689,6 +8959,12 @@
         "node": ">=8"
       }
     },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "license": "MIT"
+    },
     "node_modules/path-to-regexp": {
       "version": "8.4.2",
       "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -8699,6 +8975,15 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/path-type": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+      "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/pathe": {
       "version": "2.0.3",
       "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -9206,6 +9491,28 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/react-diff-viewer-continued": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-4.4.0.tgz",
+      "integrity": "sha512-ezDte5YNLLLfTuaGaF2mV/8i4FRX7GdxnuA5zt7x1QjwVQHtBY0+NYzHJBwEPboOQNSx9sDU9apH2GfKoDYsyQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@emotion/css": "^11.13.5",
+        "@emotion/react": "^11.14.0",
+        "classnames": "^2.5.1",
+        "diff": "^9.0.0",
+        "js-yaml": "^4.2.0",
+        "memoize-one": "^6.0.0",
+        "refractor": "^5.0.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "peerDependencies": {
+        "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/react-dom": {
       "version": "19.2.8",
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
@@ -9245,6 +9552,12 @@
         }
       }
     },
+    "node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
+      "license": "MIT"
+    },
     "node_modules/react-markdown": {
       "version": "10.1.0",
       "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -9298,6 +9611,22 @@
         "node": ">= 4"
       }
     },
+    "node_modules/refractor": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
+      "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/hast": "^3.0.0",
+        "@types/prismjs": "^1.0.0",
+        "hastscript": "^9.0.0",
+        "parse-entities": "^4.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/rehype-katex": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
@@ -9445,6 +9774,27 @@
       "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
       "license": "MIT"
     },
+    "node_modules/resolve": {
+      "version": "1.22.12",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+      "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "is-core-module": "^2.16.1",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/resolve-from": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -9749,6 +10099,15 @@
         "node": ">=20"
       }
     },
+    "node_modules/shadcn/node_modules/diff": {
+      "version": "8.0.4",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
+      "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
     "node_modules/shebang-command": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -10114,6 +10473,18 @@
       "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
       "license": "MIT"
     },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/symbol-tree": {
       "version": "3.2.4",
       "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -11273,6 +11644,23 @@
       "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
       "license": "ISC"
     },
+    "node_modules/yaml": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+      "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+      "license": "ISC",
+      "optional": true,
+      "peer": true,
+      "bin": {
+        "yaml": "bin.mjs"
+      },
+      "engines": {
+        "node": ">= 14.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/eemeli"
+      }
+    },
     "node_modules/yocto-spinner": {
       "version": "1.2.2",
       "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz",

+ 3 - 0
package.json

@@ -37,6 +37,7 @@
     "@tauri-apps/plugin-updater": "^2.10.1",
     "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "diff": "^9.0.0",
     "graphology": "^0.26.0",
     "graphology-communities-louvain": "^2.0.2",
     "graphology-layout-forceatlas2": "^0.10.1",
@@ -50,6 +51,7 @@
     "pangu": "^9.1.0",
     "pinyin-pro": "^3.29.1",
     "react": "^19.2.8",
+    "react-diff-viewer-continued": "^4.4.0",
     "react-dom": "^19.2.8",
     "react-i18next": "^17.0.11",
     "react-markdown": "^10.1.0",
@@ -66,6 +68,7 @@
   },
   "devDependencies": {
     "@tauri-apps/cli": "^2.11.4",
+    "@types/diff": "^7.0.2",
     "@types/js-yaml": "^4.0.9",
     "@types/node": "^24.13.3",
     "@types/react": "^19.2.18",

+ 11 - 2
src/components/common/ai-change-compare-panel.spec.tsx

@@ -4,8 +4,8 @@ import React, { act } from "react"
 import { createRoot, type Root } from "react-dom/client"
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 
-vi.mock("./monaco-diff-editor", () => ({
-  MonacoDiffEditor: ({
+vi.mock("./source-diff-editor", () => ({
+  SourceDiffEditor: ({
     originalValue,
     modifiedValue,
     onChange,
@@ -53,6 +53,15 @@ describe("calculateAiChangeLineStats", () => {
     expect(calculateAiChangeLineStats("A\r\nB", "A\nB"))
       .toEqual({ adds: 0, removes: 0 })
   })
+
+  it("does not treat leading indentation as a line change", () => {
+    expect(calculateAiChangeLineStats("    A\n    B", "A\nB"))
+      .toEqual({ adds: 0, removes: 0 })
+    expect(calculateAiChangeLineStats("\u3000加法尔", "加法尔"))
+      .toEqual({ adds: 0, removes: 0 })
+    expect(calculateAiChangeLineStats("    A\nB", "A\nX"))
+      .toEqual({ adds: 1, removes: 1 })
+  })
 })
 
 describe("AiChangeComparePanel", () => {

+ 18 - 7
src/components/common/ai-change-compare-panel.tsx

@@ -1,8 +1,15 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from "react"
 import { Code, Eye } from "lucide-react"
 import ReactMarkdown from "react-markdown"
-import { MonacoDiffEditor } from "./monaco-diff-editor"
-import { computeLineDiff } from "@/lib/utils/diff"
+import { diffLines } from "diff"
+import { SourceDiffEditor } from "./source-diff-editor"
+import { prepareDiffText } from "@/lib/utils/diff"
+
+function normalizeDiffText(value: string): string {
+  const normalized = prepareDiffText(value)
+  if (normalized.length === 0 || normalized.endsWith("\n")) return normalized
+  return `${normalized}\n`
+}
 
 export interface AiChangeComparePanelProps {
   originalContent: string
@@ -21,10 +28,14 @@ export function calculateAiChangeLineStats(
   originalContent: string,
   modifiedContent: string,
 ): { adds: number; removes: number } {
-  return computeLineDiff(originalContent, modifiedContent).reduce(
-    (stats, line) => {
-      if (line.type === "add") stats.adds += 1
-      else if (line.type === "remove") stats.removes += 1
+  return diffLines(
+    normalizeDiffText(originalContent),
+    normalizeDiffText(modifiedContent),
+  ).reduce(
+    (stats, change) => {
+      const lineCount = change.count ?? 0
+      if (change.added) stats.adds += lineCount
+      else if (change.removed) stats.removes += lineCount
       return stats
     },
     { adds: 0, removes: 0 },
@@ -97,7 +108,7 @@ export function AiChangeComparePanel({
 
       <div className="min-h-0 flex-1 overflow-hidden">
         {viewMode === "source" ? (
-          <MonacoDiffEditor
+          <SourceDiffEditor
             originalValue={originalContent}
             modifiedValue={modifiedContent}
             originalLabel={originalLabel}

+ 2 - 2
src/components/common/ai-change-review.spec.tsx

@@ -4,8 +4,8 @@ import { act } from "react"
 import { createRoot, type Root } from "react-dom/client"
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 
-vi.mock("./monaco-diff-editor", () => ({
-  MonacoDiffEditor: ({ originalValue, modifiedValue, onChange }: {
+vi.mock("./source-diff-editor", () => ({
+  SourceDiffEditor: ({ originalValue, modifiedValue, onChange }: {
     originalValue: string
     modifiedValue: string
     onChange: (value: string) => void

+ 0 - 44
src/components/common/monaco-diff-editor.spec.tsx

@@ -1,44 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react"
-import { createRoot } from "react-dom/client"
-import { describe, expect, it, vi } from "vitest"
-import { MonacoDiffEditor } from "./monaco-diff-editor"
-
-;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
-
-describe("MonacoDiffEditor", () => {
-  it("aligns inserted lines as a real line diff while keeping the candidate editable", () => {
-    const host = document.createElement("div")
-    document.body.appendChild(host)
-    const root = createRoot(host)
-    const onChange = vi.fn()
-
-    act(() => {
-      root.render(
-        <MonacoDiffEditor
-          originalValue={"A\nB"}
-          modifiedValue={"X\nA\nB"}
-          onChange={onChange}
-        />,
-      )
-    })
-
-    expect(document.body.querySelectorAll('[data-diff-type="add"]')).toHaveLength(1)
-    expect(document.body.querySelectorAll('[data-diff-type="remove"]')).toHaveLength(0)
-    expect(document.body.querySelectorAll('[data-diff-type="unchanged"]')).toHaveLength(2)
-    expect(document.body.querySelector('[data-diff-type="add"]')?.textContent).toContain("X")
-
-    const editor = document.body.querySelector<HTMLTextAreaElement>('textarea[aria-label="最新源码"]')
-    expect(editor?.value).toBe("X\nA\nB")
-    act(() => {
-      const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
-      setter?.call(editor, "edited")
-      editor!.dispatchEvent(new Event("input", { bubbles: true }))
-    })
-    expect(onChange).toHaveBeenCalledWith("edited")
-
-    act(() => root.unmount())
-    host.remove()
-  })
-})

+ 0 - 98
src/components/common/monaco-diff-editor.tsx

@@ -1,98 +0,0 @@
-import { useEffect, useMemo, useRef } from "react"
-import { computeLineDiff } from "@/lib/utils/diff"
-
-interface MonacoDiffEditorProps {
-  originalValue: string
-  modifiedValue: string
-  onChange: (value: string) => void
-  language?: string
-  originalLabel?: string
-  modifiedLabel?: string
-  readOnly?: boolean
-}
-
-export function MonacoDiffEditor({
-  originalValue,
-  modifiedValue,
-  onChange,
-  originalLabel = "原始内容",
-  modifiedLabel = "最新内容",
-  readOnly = false,
-}: MonacoDiffEditorProps) {
-  const textareaRef = useRef<HTMLTextAreaElement | null>(null)
-  const preventSync = useRef(false)
-  const diffLines = useMemo(
-    () => computeLineDiff(originalValue, modifiedValue),
-    [modifiedValue, originalValue],
-  )
-
-  useEffect(() => {
-    const textarea = textareaRef.current
-    if (!textarea) return
-    if (textarea.value !== modifiedValue) {
-      preventSync.current = true
-      textarea.value = modifiedValue
-      preventSync.current = false
-    }
-  }, [modifiedValue])
-
-  useEffect(() => {
-    const textarea = textareaRef.current
-    if (!textarea) return
-    const handler = () => {
-      if (preventSync.current) return
-      onChange(textarea.value)
-    }
-    textarea.addEventListener("input", handler)
-    return () => textarea.removeEventListener("input", handler)
-  }, [onChange])
-
-  return (
-    <div className="grid h-full min-h-0 grid-cols-1 overflow-y-auto md:grid-cols-2 md:divide-x md:overflow-hidden">
-      <div className="flex min-h-48 flex-col overflow-hidden border-b md:min-h-0 md:border-b-0">
-        <div className="border-b bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground">
-          {originalLabel} / 逐行差异
-        </div>
-        <div
-          aria-label="逐行差异"
-          className="min-h-0 flex-1 overflow-auto py-2 font-mono text-xs leading-relaxed"
-        >
-          {diffLines.length > 0 ? diffLines.map((line, index) => (
-            <div
-              key={`${line.type}-${line.originalLine ?? 0}-${line.modifiedLine ?? 0}-${index}`}
-              data-diff-type={line.type}
-              className={`grid min-w-max grid-cols-[2.5rem_2.5rem_1.5rem_minmax(12rem,1fr)] px-2 py-0.5 ${
-                line.type === "add"
-                  ? "bg-green-100 text-green-800 dark:bg-green-950/50 dark:text-green-300"
-                  : line.type === "remove"
-                    ? "bg-red-100 text-red-800 dark:bg-red-950/50 dark:text-red-300"
-                    : "text-muted-foreground"
-              }`}
-            >
-              <span className="select-none text-right opacity-50">{line.originalLine ?? ""}</span>
-              <span className="select-none text-right opacity-50">{line.modifiedLine ?? ""}</span>
-              <span className="select-none text-center opacity-70">
-                {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}
-              </span>
-              <span className="whitespace-pre-wrap break-words pl-2">{line.content || " "}</span>
-            </div>
-          )) : (
-            <div className="px-3 py-2 text-muted-foreground">暂无差异</div>
-          )}
-        </div>
-      </div>
-      <div className="flex min-h-48 flex-col overflow-hidden md:min-h-0">
-        <div className="border-b bg-muted/40 px-3 py-1 text-xs font-medium text-green-700 dark:text-green-400">
-          {modifiedLabel}{readOnly ? "" : "(可编辑)"}
-        </div>
-        <textarea
-          ref={textareaRef}
-          aria-label="最新源码"
-          className="flex-1 resize-none overflow-auto whitespace-pre-wrap break-words bg-transparent p-3 font-mono text-xs leading-relaxed outline-none"
-          spellCheck={false}
-          readOnly={readOnly}
-        />
-      </div>
-    </div>
-  )
-}

+ 80 - 0
src/components/common/source-diff-editor.spec.tsx

@@ -0,0 +1,80 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot } from "react-dom/client"
+import { describe, expect, it, vi } from "vitest"
+import { SourceDiffEditor } from "./source-diff-editor"
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
+  const started = Date.now()
+  while (!predicate()) {
+    if (Date.now() - started > timeoutMs) {
+      throw new Error("timed out waiting for diff viewer")
+    }
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 10))
+    })
+  }
+}
+
+describe("SourceDiffEditor", () => {
+  it("aligns inserted lines as a real line diff while keeping the candidate editable", async () => {
+    const host = document.createElement("div")
+    document.body.appendChild(host)
+    const root = createRoot(host)
+    const onChange = vi.fn()
+
+    await act(async () => {
+      root.render(
+        <SourceDiffEditor
+          originalValue={"A\nB"}
+          modifiedValue={"X\nA\nB"}
+          onChange={onChange}
+        />,
+      )
+    })
+
+    await waitFor(() => document.body.querySelectorAll('[title="Added line"]').length === 1)
+    expect(document.body.querySelector('[title="Added line"]')?.textContent).toContain("X")
+    expect(document.body.querySelectorAll('[title="Removed line"]')).toHaveLength(0)
+    expect(document.body.textContent).toContain("A")
+    expect(document.body.textContent).toContain("B")
+
+    const editor = document.body.querySelector<HTMLTextAreaElement>('textarea[aria-label="最新源码"]')
+    expect(editor?.value).toBe("X\nA\nB")
+    act(() => {
+      const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setter?.call(editor, "edited")
+      editor!.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+    expect(onChange).toHaveBeenCalledWith("edited")
+
+    await act(async () => root.unmount())
+    host.remove()
+  })
+
+  it("does not mark leading indentation as added or removed lines", async () => {
+    const host = document.createElement("div")
+    document.body.appendChild(host)
+    const root = createRoot(host)
+
+    await act(async () => {
+      root.render(
+        <SourceDiffEditor
+          originalValue={"    加法尔·达乌德关掉了采购代表处的对外窗口。"}
+          modifiedValue={"加法尔·达乌德关掉了采购代表处的对外窗口。"}
+          onChange={vi.fn()}
+        />,
+      )
+    })
+
+    await waitFor(() => (document.body.textContent ?? "").includes("加法尔·达乌德"))
+    expect(document.body.querySelectorAll('[title="Added line"]')).toHaveLength(0)
+    expect(document.body.querySelectorAll('[title="Removed line"]')).toHaveLength(0)
+
+    await act(async () => root.unmount())
+    host.remove()
+  })
+})

+ 143 - 0
src/components/common/source-diff-editor.tsx

@@ -0,0 +1,143 @@
+import { useEffect, useRef, useState } from "react"
+import ReactDiffViewer, { DiffMethod, type ReactDiffViewerStylesOverride } from "react-diff-viewer-continued"
+import { prepareDiffText } from "@/lib/utils/diff"
+
+interface SourceDiffEditorProps {
+  originalValue: string
+  modifiedValue: string
+  onChange: (value: string) => void
+  originalLabel?: string
+  modifiedLabel?: string
+  readOnly?: boolean
+}
+
+const DIFF_STYLES: ReactDiffViewerStylesOverride = {
+  variables: {
+    light: {
+      diffViewerBackground: "transparent",
+    },
+    dark: {
+      diffViewerBackground: "transparent",
+    },
+  },
+  diffContainer: {
+    width: "100%",
+    fontSize: "0.75rem",
+    lineHeight: 1.625,
+    border: "none",
+    pre: {
+      fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
+      fontSize: "0.75rem",
+      lineHeight: 1.625,
+      whiteSpace: "pre-wrap",
+      wordBreak: "break-word",
+    },
+  },
+  content: {
+    width: "100%",
+  },
+  contentText: {
+    fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
+    fontSize: "0.75rem",
+    lineHeight: 1.625,
+    whiteSpace: "pre-wrap",
+    wordBreak: "break-word",
+  },
+}
+
+function useDocumentDarkClass(): boolean {
+  const [isDark, setIsDark] = useState(
+    () => typeof document !== "undefined" && document.documentElement.classList.contains("dark"),
+  )
+
+  useEffect(() => {
+    const root = document.documentElement
+    const sync = () => setIsDark(root.classList.contains("dark"))
+    sync()
+    const observer = new MutationObserver(sync)
+    observer.observe(root, { attributes: true, attributeFilter: ["class"] })
+    return () => observer.disconnect()
+  }, [])
+
+  return isDark
+}
+
+export function SourceDiffEditor({
+  originalValue,
+  modifiedValue,
+  onChange,
+  originalLabel = "原始内容",
+  modifiedLabel = "最新内容",
+  readOnly = false,
+}: SourceDiffEditorProps) {
+  const textareaRef = useRef<HTMLTextAreaElement | null>(null)
+  const preventSync = useRef(false)
+  const isDark = useDocumentDarkClass()
+  const hasContent = originalValue.length > 0 || modifiedValue.length > 0
+  const originalForDiff = prepareDiffText(originalValue)
+  const modifiedForDiff = prepareDiffText(modifiedValue)
+
+  useEffect(() => {
+    const textarea = textareaRef.current
+    if (!textarea) return
+    if (textarea.value !== modifiedValue) {
+      preventSync.current = true
+      textarea.value = modifiedValue
+      preventSync.current = false
+    }
+  }, [modifiedValue])
+
+  useEffect(() => {
+    const textarea = textareaRef.current
+    if (!textarea) return
+    const handler = () => {
+      if (preventSync.current) return
+      onChange(textarea.value)
+    }
+    textarea.addEventListener("input", handler)
+    return () => textarea.removeEventListener("input", handler)
+  }, [onChange])
+
+  return (
+    <div className="grid h-full min-h-0 grid-cols-1 overflow-y-auto md:grid-cols-2 md:divide-x md:overflow-hidden">
+      <div className="flex min-h-48 flex-col overflow-hidden border-b md:min-h-0 md:border-b-0">
+        <div className="border-b bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground">
+          {originalLabel} / 逐行差异
+        </div>
+        <div
+          aria-label="逐行差异"
+          className="min-h-0 flex-1 overflow-auto py-2 font-mono text-xs leading-relaxed"
+        >
+          {hasContent ? (
+            <ReactDiffViewer
+              oldValue={originalForDiff}
+              newValue={modifiedForDiff}
+              splitView={false}
+              compareMethod={DiffMethod.CHARS}
+              showDiffOnly={false}
+              hideLineNumbers={false}
+              hideSummary
+              useDarkTheme={isDark}
+              disableWorker
+              styles={DIFF_STYLES}
+            />
+          ) : (
+            <div className="px-3 py-2 text-muted-foreground">暂无差异</div>
+          )}
+        </div>
+      </div>
+      <div className="flex min-h-48 flex-col overflow-hidden md:min-h-0">
+        <div className="border-b bg-muted/40 px-3 py-1 text-xs font-medium text-green-700 dark:text-green-400">
+          {modifiedLabel}{readOnly ? "" : "(可编辑)"}
+        </div>
+        <textarea
+          ref={textareaRef}
+          aria-label="最新源码"
+          className="flex-1 resize-none overflow-auto whitespace-pre-wrap break-words bg-transparent p-3 font-mono text-xs leading-relaxed outline-none"
+          spellCheck={false}
+          readOnly={readOnly}
+        />
+      </div>
+    </div>
+  )
+}

+ 7 - 0
src/lib/utils/diff.ts

@@ -1,3 +1,10 @@
+const LEADING_LINE_WHITESPACE = /^[\t \u00A0\u3000]+/gm
+
+/** 对比用:统一换行,并丢掉行首缩进(空格/Tab/全角空格)。 */
+export function prepareDiffText(value: string): string {
+  return value.replace(/\r\n?/g, "\n").replace(LEADING_LINE_WHITESPACE, "")
+}
+
 export type DiffLineType = "add" | "remove" | "unchanged"
 
 export interface DiffLine {