mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
feat(diffs): render diffs with @pierre/diffs instead of Monaco
Replaces the per-file Monaco DiffEditor across every diff surface with @pierre/diffs, which renders a CSS-grid row list and highlights in a Shiki worker pool instead of constructing an editor per visible file. - Combined diff (source control, PR page, GitHub dialog) and the single-file diff tab now share one PierreDiffSurface renderer. - Line notes move from Monaco decorations/view zones to native annotations; drafts render inline on their anchor line. - Cmd+F enters Pierre's edit-mode search panel, since it ships no read-only find. - Diff navigation derives changes from hunk metadata rather than a mounted editor. - Drops the Monaco diff option builders, model-swap/disposal guards, and the large-diff model lifecycle; Monaco stays for the file editor. - Raises the combined-diff loader concurrency, whose serialization existed only to stagger Monaco mounts.
This commit is contained in:
@@ -165,6 +165,7 @@
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
"@linear/sdk": "^82.1.0",
|
||||
"@parcel/watcher": "^2.5.6",
|
||||
"@pierre/diffs": "1.3.6",
|
||||
"@xterm/addon-serialize": "0.15.0-beta.300",
|
||||
"@xterm/headless": "6.1.0-beta.302",
|
||||
"agent-browser": "~0.27.0",
|
||||
|
||||
Generated
+220
@@ -140,6 +140,9 @@ importers:
|
||||
'@parcel/watcher':
|
||||
specifier: ^2.5.6
|
||||
version: 2.5.6
|
||||
'@pierre/diffs':
|
||||
specifier: 1.3.6
|
||||
version: 1.3.6(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.300
|
||||
version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
|
||||
@@ -1841,6 +1844,36 @@ packages:
|
||||
resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
|
||||
'@pierre/diffs@1.3.6':
|
||||
resolution: {integrity: sha512-a3woaW2QHy78JDxPJK0OJzwZUN4xoQLLIS/pceO8X6+L8gA5D682mP7/w3YxxEVRPXOaoe/p/RJ5Oj/3nrEzew==}
|
||||
peerDependencies:
|
||||
react: ^18.3.1 || ^19.0.0
|
||||
react-dom: ^18.3.1 || ^19.0.0
|
||||
|
||||
'@pierre/theme@2.0.0':
|
||||
resolution: {integrity: sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw==}
|
||||
engines: {vscode: ^1.0.0}
|
||||
|
||||
'@pierre/theming@1.0.1':
|
||||
resolution: {integrity: sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA==}
|
||||
peerDependencies:
|
||||
'@pierre/theme': ^1.1.0 || ^2.0.0
|
||||
'@shikijs/themes': ^3.0.0 || ^4.0.0
|
||||
react: ^18.3.1 || ^19.0.0
|
||||
react-dom: ^18.3.1 || ^19.0.0
|
||||
shiki: ^3.0.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
'@pierre/theme':
|
||||
optional: true
|
||||
'@shikijs/themes':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
shiki:
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2640,6 +2673,41 @@ packages:
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
'@shikijs/core@4.4.3':
|
||||
resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-javascript@4.4.3':
|
||||
resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-oniguruma@4.4.3':
|
||||
resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@4.4.3':
|
||||
resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/primitive@4.4.3':
|
||||
resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/themes@4.4.3':
|
||||
resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/transformers@4.4.3':
|
||||
resolution: {integrity: sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/types@4.4.3':
|
||||
resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@sindresorhus/is@4.6.0':
|
||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3271,6 +3339,9 @@ packages:
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/hast@3.0.5':
|
||||
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
|
||||
|
||||
'@types/http-cache-semantics@4.2.0':
|
||||
resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
|
||||
|
||||
@@ -4246,6 +4317,10 @@ packages:
|
||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
diff@9.0.0:
|
||||
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
@@ -4761,6 +4836,9 @@ packages:
|
||||
hast-util-sanitize@5.0.2:
|
||||
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
||||
|
||||
@@ -5262,6 +5340,9 @@ packages:
|
||||
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lru_map@0.4.1:
|
||||
resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==}
|
||||
|
||||
lucide-react@0.577.0:
|
||||
resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==}
|
||||
peerDependencies:
|
||||
@@ -5654,6 +5735,12 @@ packages:
|
||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
oniguruma-parser@0.12.2:
|
||||
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
|
||||
|
||||
oniguruma-to-es@4.3.6:
|
||||
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
|
||||
|
||||
open@11.0.0:
|
||||
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -6111,6 +6198,15 @@ packages:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
regex-utilities@2.3.0:
|
||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||
|
||||
regex@6.1.0:
|
||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||
|
||||
rehype-highlight@7.0.2:
|
||||
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
|
||||
|
||||
@@ -6380,6 +6476,10 @@ packages:
|
||||
sherpa-onnx@1.12.37:
|
||||
resolution: {integrity: sha512-3luwSdHwR8BtJiiFwqHfb15FE2FX0KsN4aOBbfq9Ma23r3w9C3bprFc/WBusXk56nUbzcEN5YczN7t9w1JwdtQ==}
|
||||
|
||||
shiki@4.4.3:
|
||||
resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -8174,6 +8274,30 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
webcrypto-core: 1.9.2
|
||||
|
||||
'@pierre/diffs@1.3.6(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@pierre/theme': 2.0.0
|
||||
'@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3)
|
||||
'@shikijs/transformers': 4.4.3
|
||||
diff: 9.0.0
|
||||
hast-util-to-html: 9.0.5
|
||||
lru_map: 0.4.1
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
shiki: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- '@shikijs/themes'
|
||||
|
||||
'@pierre/theme@2.0.0': {}
|
||||
|
||||
'@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3)':
|
||||
optionalDependencies:
|
||||
'@pierre/theme': 2.0.0
|
||||
'@shikijs/themes': 4.4.3
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
shiki: 4.4.3
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
dependencies:
|
||||
playwright: 1.59.1
|
||||
@@ -8994,6 +9118,51 @@ snapshots:
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@shikijs/core@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/primitive': 4.4.3
|
||||
'@shikijs/types': 4.4.3
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.3
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-oniguruma@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.3
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.3
|
||||
|
||||
'@shikijs/primitive@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.3
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
'@shikijs/themes@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.3
|
||||
|
||||
'@shikijs/transformers@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/core': 4.4.3
|
||||
'@shikijs/types': 4.4.3
|
||||
|
||||
'@shikijs/types@4.4.3':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@sindresorhus/is@4.6.0': {}
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
@@ -9621,6 +9790,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/hast@3.0.5':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/http-cache-semantics@4.2.0': {}
|
||||
|
||||
'@types/katex@0.16.8': {}
|
||||
@@ -10552,6 +10725,8 @@ snapshots:
|
||||
|
||||
diff@8.0.4: {}
|
||||
|
||||
diff@9.0.0: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dir-compare@4.2.0:
|
||||
@@ -11232,6 +11407,20 @@ snapshots:
|
||||
'@ungap/structured-clone': 1.3.1
|
||||
unist-util-position: 5.0.0
|
||||
|
||||
hast-util-to-html@9.0.5:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
ccount: 2.0.1
|
||||
comma-separated-tokens: 2.0.3
|
||||
hast-util-whitespace: 3.0.0
|
||||
html-void-elements: 3.0.0
|
||||
mdast-util-to-hast: 13.2.1
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
stringify-entities: 4.0.4
|
||||
zwitch: 2.0.4
|
||||
|
||||
hast-util-to-jsx-runtime@2.3.6:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -11701,6 +11890,8 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 4.0.0
|
||||
|
||||
lru_map@0.4.1: {}
|
||||
|
||||
lucide-react@0.577.0(react@19.2.8):
|
||||
dependencies:
|
||||
react: 19.2.8
|
||||
@@ -12333,6 +12524,14 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
|
||||
oniguruma-parser@0.12.2: {}
|
||||
|
||||
oniguruma-to-es@4.3.6:
|
||||
dependencies:
|
||||
oniguruma-parser: 0.12.2
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
open@11.0.0:
|
||||
dependencies:
|
||||
default-browser: 5.5.0
|
||||
@@ -12933,6 +13132,16 @@ snapshots:
|
||||
indent-string: 4.0.0
|
||||
strip-indent: 3.0.0
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex-utilities@2.3.0: {}
|
||||
|
||||
regex@6.1.0:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
rehype-highlight@7.0.2:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -13276,6 +13485,17 @@ snapshots:
|
||||
|
||||
sherpa-onnx@1.12.37: {}
|
||||
|
||||
shiki@4.4.3:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.4.3
|
||||
'@shikijs/engine-javascript': 4.4.3
|
||||
'@shikijs/engine-oniguruma': 4.4.3
|
||||
'@shikijs/langs': 4.4.3
|
||||
'@shikijs/themes': 4.4.3
|
||||
'@shikijs/types': 4.4.3
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
@@ -3037,3 +3037,11 @@ html.native-shell .app-layout {
|
||||
inset 0 1px 0 0 color-mix(in srgb, var(--foreground) 5%, transparent),
|
||||
0 10px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* Why: inside a Pierre annotation row the diff already owns placement, so the
|
||||
note draft drops the overlay's absolute positioning and fills the row. */
|
||||
.orca-diff-comment-popover-inline {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ import { resolveDiffCommentPopoverTop } from './diff-comment-popover-position'
|
||||
type Props = {
|
||||
lineNumber: number
|
||||
startLine?: number
|
||||
top: number
|
||||
/** Overlay coordinates. Omitted when the popover renders inline in a diff annotation row. */
|
||||
top?: number
|
||||
left?: number
|
||||
/** `inline` drops absolute positioning so a Pierre annotation row can own the layout. */
|
||||
layout?: 'overlay' | 'inline'
|
||||
// Anchor line height, used to flip the popover above the line near the viewport bottom; 0 for non-Monaco callers.
|
||||
lineHeight?: number
|
||||
title?: string
|
||||
@@ -37,6 +40,7 @@ export function DiffCommentPopover({
|
||||
startLine,
|
||||
top,
|
||||
left,
|
||||
layout = 'overlay',
|
||||
lineHeight = 0,
|
||||
title,
|
||||
placeholder = 'Add note for the AI',
|
||||
@@ -59,15 +63,20 @@ export function DiffCommentPopover({
|
||||
// Why: stable per-instance id so coexisting popovers don't collide on aria-labelledby references.
|
||||
const labelId = useId()
|
||||
// Why: seed at `top` for a correct first paint when there's room below; the layout effect flips it above the line if clipped.
|
||||
const [resolvedTop, setResolvedTop] = useState(top)
|
||||
const [resolvedTop, setResolvedTop] = useState(top ?? 0)
|
||||
|
||||
// Why: mirror `top` into a ref so the measure callback stays stable and the ResizeObserver isn't re-mounted each scroll frame.
|
||||
const topRef = useRef(top)
|
||||
topRef.current = top
|
||||
const topRef = useRef(top ?? 0)
|
||||
topRef.current = top ?? 0
|
||||
const lineHeightRef = useRef(lineHeight)
|
||||
lineHeightRef.current = lineHeight
|
||||
const layoutRef = useRef(layout)
|
||||
layoutRef.current = layout
|
||||
|
||||
const measureResolvedTop = useCallback((): void => {
|
||||
if (layoutRef.current === 'inline') {
|
||||
return
|
||||
}
|
||||
const popover = popoverRef.current
|
||||
const container = popover?.parentElement
|
||||
if (!popover || !container) {
|
||||
@@ -174,8 +183,16 @@ export function DiffCommentPopover({
|
||||
return (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="orca-diff-comment-popover"
|
||||
style={{ top: `${resolvedTop}px`, ...(left == null ? {} : { left: `${left}px` }) }}
|
||||
className={
|
||||
layout === 'inline'
|
||||
? 'orca-diff-comment-popover orca-diff-comment-popover-inline'
|
||||
: 'orca-diff-comment-popover'
|
||||
}
|
||||
style={
|
||||
layout === 'inline'
|
||||
? undefined
|
||||
: { top: `${resolvedTop}px`, ...(left == null ? {} : { left: `${left}px` }) }
|
||||
}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelId}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { formatDiffComments } from '@/lib/diff-comments-format'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { NotesSendMenuScope } from '../editor/NotesSendMenu'
|
||||
import type { DecoratedDiffComment } from './decorated-diff-comment'
|
||||
|
||||
/** Send-menu scope for a single note, shared by every diff annotation surface. */
|
||||
export function getSingleCommentSendScopes(
|
||||
comment: DecoratedDiffComment,
|
||||
formatCommentPrompt?: (comment: DecoratedDiffComment) => string
|
||||
): NotesSendMenuScope<DecoratedDiffComment>[] {
|
||||
return [
|
||||
{
|
||||
id: 'note',
|
||||
label: translate(
|
||||
'auto.components.diff.comments.useDiffCommentDecorator.995fa28b50',
|
||||
'This note'
|
||||
),
|
||||
notes: comment.sentAt ? [] : [comment],
|
||||
prompt: formatCommentPrompt ? formatCommentPrompt(comment) : formatDiffComments([comment])
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover'
|
||||
import { combinedDiffSectionScrollbarOptions } from './diff-editor-scrollbar-options'
|
||||
import { isCombinedDiffSizeUnknown } from './combined-diff-on-demand-load'
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { LargeDiffFallback } from './LargeDiffFallback'
|
||||
import { LargeDiffLoadPrompt } from './LargeDiffLoadPrompt'
|
||||
import { buildDiffEditorWhitespaceOptions } from './diff-editor-whitespace-options'
|
||||
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
|
||||
import { monacoFindOptions } from './monaco-find-options'
|
||||
|
||||
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
|
||||
|
||||
@@ -23,31 +17,14 @@ type DiffSectionBodyProps = {
|
||||
sectionBodyRef: RefObject<HTMLDivElement | null>
|
||||
sectionBodyHeight: number | undefined
|
||||
useIntrinsicImageHeight: boolean
|
||||
popover: {
|
||||
lineNumber: number
|
||||
startLine?: number
|
||||
top: number
|
||||
left?: number
|
||||
lineHeight: number
|
||||
} | null
|
||||
addLineCommentPlaceholder?: string
|
||||
addLineCommentLabel?: string
|
||||
isBranchMode: boolean
|
||||
sideBySide: boolean
|
||||
isDark: boolean
|
||||
language: string
|
||||
modelPathBase: string
|
||||
isEditable: boolean
|
||||
diffEditorFontSize: number
|
||||
diffWordWrap?: boolean
|
||||
diffShowWhitespace?: boolean
|
||||
editorFontFamily?: string
|
||||
onCancelComment: () => void
|
||||
onSubmitComment: (body: string) => Promise<void>
|
||||
/** Renders the text diff itself; kept as a callback so this file owns only the branching. */
|
||||
renderDiff: () => React.ReactNode
|
||||
onRetrySection: (index: number) => void
|
||||
onLoadDeferredSection: (index: number) => void
|
||||
onSaveLimitedDiff: () => void
|
||||
onMount: DiffOnMount
|
||||
}
|
||||
|
||||
export function DiffSectionBody({
|
||||
@@ -56,25 +33,13 @@ export function DiffSectionBody({
|
||||
sectionBodyRef,
|
||||
sectionBodyHeight,
|
||||
useIntrinsicImageHeight,
|
||||
popover,
|
||||
addLineCommentPlaceholder,
|
||||
addLineCommentLabel,
|
||||
isBranchMode,
|
||||
sideBySide,
|
||||
isDark,
|
||||
language,
|
||||
modelPathBase,
|
||||
isEditable,
|
||||
diffEditorFontSize,
|
||||
diffWordWrap,
|
||||
diffShowWhitespace,
|
||||
editorFontFamily,
|
||||
onCancelComment,
|
||||
onSubmitComment,
|
||||
renderDiff,
|
||||
onRetrySection,
|
||||
onLoadDeferredSection,
|
||||
onSaveLimitedDiff,
|
||||
onMount
|
||||
onSaveLimitedDiff
|
||||
}: DiffSectionBodyProps): React.JSX.Element {
|
||||
const renderLimit = section.largeDiffRenderLimit?.limited ? section.largeDiffRenderLimit : null
|
||||
|
||||
@@ -84,23 +49,6 @@ export function DiffSectionBody({
|
||||
className={cn('relative', useIntrinsicImageHeight && 'overflow-visible')}
|
||||
style={sectionBodyHeight === undefined ? undefined : { height: sectionBodyHeight }}
|
||||
>
|
||||
{popover && !renderLimit?.limited ? (
|
||||
// Why: key by lineNumber so the popover remounts when the anchor
|
||||
// line changes instead of leaking draft state across lines.
|
||||
<DiffCommentPopover
|
||||
key={popover.lineNumber}
|
||||
lineNumber={popover.lineNumber}
|
||||
startLine={popover.startLine}
|
||||
top={popover.top}
|
||||
left={popover.left}
|
||||
lineHeight={popover.lineHeight}
|
||||
placeholder={addLineCommentPlaceholder}
|
||||
submitLabel={addLineCommentLabel}
|
||||
submittingLabel="Posting…"
|
||||
onCancel={onCancelComment}
|
||||
onSubmit={onSubmitComment}
|
||||
/>
|
||||
) : null}
|
||||
{section.loadOnDemand ? (
|
||||
<LargeDiffLoadPrompt
|
||||
sizeUnknown={isCombinedDiffSizeUnknown(section)}
|
||||
@@ -184,37 +132,7 @@ export function DiffSectionBody({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
original={section.originalContent}
|
||||
modified={section.modifiedContent}
|
||||
theme={isDark ? 'vs-dark' : 'vs'}
|
||||
onMount={onMount}
|
||||
// Why: @monaco-editor/react can dispose models before widget teardown.
|
||||
// Keep them through unmount and dispose unattached models next tick.
|
||||
originalModelPath={`${modelPathBase}:original`}
|
||||
modifiedModelPath={`${modelPathBase}:modified`}
|
||||
keepCurrentOriginalModel
|
||||
keepCurrentModifiedModel
|
||||
options={{
|
||||
readOnly: !isEditable,
|
||||
originalEditable: false,
|
||||
renderSideBySide: sideBySide,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: diffEditorFontSize,
|
||||
fontFamily: editorFontFamily || 'monospace',
|
||||
lineNumbers: 'on',
|
||||
...buildDiffEditorWordWrapOptions(diffWordWrap),
|
||||
...buildDiffEditorWhitespaceOptions(diffShowWhitespace),
|
||||
automaticLayout: true,
|
||||
renderOverviewRuler: false,
|
||||
scrollbar: combinedDiffSectionScrollbarOptions,
|
||||
hideUnchangedRegions: { enabled: true },
|
||||
find: monacoFindOptions
|
||||
}}
|
||||
/>
|
||||
renderDiff()
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { DiffOnMount } from '@monaco-editor/react'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import { monaco } from '@/lib/monaco-setup'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { PostRenderPhase } from '@pierre/diffs'
|
||||
import { useAppStore } from '@/store'
|
||||
import { computeDiffEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
|
||||
import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector'
|
||||
import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator'
|
||||
import {
|
||||
getDiffCommentPopoverLeft,
|
||||
getDiffCommentPopoverTop
|
||||
} from '../diff-comments/diff-comment-popover-position'
|
||||
import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options'
|
||||
import { DiffSectionHeader } from './DiffSectionHeader'
|
||||
import type { DiffComment } from '../../../../shared/diff-comment-types'
|
||||
import { isDiffComment } from '@/lib/diff-comment-compat'
|
||||
import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts'
|
||||
import { installEditorSaveShortcut } from './editor-shortcuts'
|
||||
import { DiffSectionHeader } from './DiffSectionHeader'
|
||||
import { DiffSectionBody } from './DiffSectionBody'
|
||||
import { useDiffSectionLayoutMetrics } from './useDiffSectionLayoutMetrics'
|
||||
import { getLiveDiffSectionRenderLimit } from './diff-section-live-render-limit'
|
||||
import { useDiffSectionFallbackCleanup } from './useDiffSectionFallbackCleanup'
|
||||
import { submitDiffSectionComment } from './diff-section-comment-submit'
|
||||
import type { DiffSectionItemProps } from './diff-section-item-props'
|
||||
import { useDiffSectionModelLifecycle } from './use-diff-section-model-lifecycle'
|
||||
import { PierreDiffSurface } from './pierre-diff/PierreDiffSurface'
|
||||
import { buildPierreFileDiff } from './pierre-diff/pierre-diff-metadata'
|
||||
import { buildPierreParseDiffOptions } from './pierre-diff/pierre-diff-options'
|
||||
import type { DecoratedDiffComment } from '../diff-comments/decorated-diff-comment'
|
||||
|
||||
const EMPTY_DIFF_COMMENTS: readonly DecoratedDiffComment[] = []
|
||||
|
||||
export function DiffSectionItem({
|
||||
section,
|
||||
index,
|
||||
isBranchMode,
|
||||
sideBySide,
|
||||
isDark,
|
||||
settings,
|
||||
sectionHeight,
|
||||
worktreeId,
|
||||
@@ -48,15 +42,11 @@ export function DiffSectionItem({
|
||||
getCommentableLineNumbers,
|
||||
setSectionHeights,
|
||||
setSections,
|
||||
modifiedEditorsRef,
|
||||
handleSectionSaveRef
|
||||
}: DiffSectionItemProps): React.JSX.Element {
|
||||
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
|
||||
const addDiffComment = useAppStore((s) => s.addDiffComment)
|
||||
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
|
||||
const updateDiffComment = useAppStore((s) => s.updateDiffComment)
|
||||
const scrollToDiffCommentId = useAppStore((s) => s.scrollToDiffCommentId)
|
||||
const setScrollToDiffCommentId = useAppStore((s) => s.setScrollToDiffCommentId)
|
||||
// Why: subscribe to the raw comments array on the worktree (reference-
|
||||
// stable across unrelated store updates) and filter by filePath inside a
|
||||
// memo. Selecting a fresh `.filter(...)` result would invalidate on every
|
||||
@@ -68,271 +58,203 @@ export function DiffSectionItem({
|
||||
() => (allDiffComments ?? []).filter((c) => c.filePath === section.path && isDiffComment(c)),
|
||||
[allDiffComments, section.path]
|
||||
)
|
||||
const language = detectLanguage(section.path)
|
||||
const isEditable = section.area === 'unstaged'
|
||||
const modelPathBase = useMemo(
|
||||
() =>
|
||||
`diff-section:${encodeURIComponent(worktreeId ?? 'review')}:${encodeURIComponent(section.key)}:${section.contentGeneration ?? 0}`,
|
||||
[section.contentGeneration, section.key, worktreeId]
|
||||
)
|
||||
const diffEditorFontSize = computeDiffEditorFontSize(
|
||||
settings?.terminalFontSize ?? 13,
|
||||
editorFontZoomLevel
|
||||
)
|
||||
|
||||
const [modifiedEditor, setModifiedEditor] = useState<monacoEditor.ICodeEditor | null>(null)
|
||||
const diffEditorRef = useRef<monacoEditor.IStandaloneDiffEditor | null>(null)
|
||||
const sectionBodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const lineNumberOptionsSubRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const [popover, setPopover] = useState<{
|
||||
lineNumber: number
|
||||
startLine?: number
|
||||
top: number
|
||||
left?: number
|
||||
lineHeight: number
|
||||
} | null>(null)
|
||||
const hasLineCommentAction = Boolean(worktreeId || onAddLineComment)
|
||||
|
||||
const { disposeDiffModels, setSectionRootNode } = useDiffSectionModelLifecycle({
|
||||
modelPathBase,
|
||||
collapsed: section.collapsed
|
||||
})
|
||||
const sectionBodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const [pendingComment, setPendingComment] = useState<{
|
||||
lineNumber: number
|
||||
startLine?: number
|
||||
} | null>(null)
|
||||
|
||||
// Why: only forward the pending scroll id when it matches a comment in this
|
||||
// section so unrelated sections don't keep re-rendering their decorator
|
||||
// every time the sidebar requests a scroll elsewhere.
|
||||
const pendingScrollForThisSection = useMemo(() => {
|
||||
if (!scrollToDiffCommentId) {
|
||||
return null
|
||||
}
|
||||
return diffComments.some((c) => c.id === scrollToDiffCommentId) ? scrollToDiffCommentId : null
|
||||
}, [scrollToDiffCommentId, diffComments])
|
||||
|
||||
useDiffCommentDecorator({
|
||||
editor: hasLineCommentAction ? modifiedEditor : null,
|
||||
filePath: section.path,
|
||||
worktreeId: worktreeId ?? '',
|
||||
comments: inlineComments ?? (worktreeId ? diffComments : []),
|
||||
commentableLineNumbers: getCommentableLineNumbers?.(section),
|
||||
addButtonLabel: addLineCommentLabel,
|
||||
onAddCommentClick: ({ lineNumber, startLine, top }) =>
|
||||
setPopover({
|
||||
lineNumber,
|
||||
startLine,
|
||||
top,
|
||||
left: modifiedEditor
|
||||
? (getDiffCommentPopoverLeft(modifiedEditor, sectionBodyRef.current) ?? undefined)
|
||||
: undefined,
|
||||
lineHeight: modifiedEditor?.getOption(monaco.editor.EditorOption.lineHeight) ?? 0
|
||||
}),
|
||||
onDeleteComment: (id) => {
|
||||
if (worktreeId) {
|
||||
void deleteDiffComment(worktreeId, id)
|
||||
// Why: a fresh `[]` fallback would invalidate every memo that reads comments.
|
||||
const comments = useMemo(
|
||||
() => inlineComments ?? (worktreeId ? diffComments : EMPTY_DIFF_COMMENTS),
|
||||
[diffComments, inlineComments, worktreeId]
|
||||
)
|
||||
// Why: PR review only accepts comments on lines GitHub exposes in the patch.
|
||||
const commentableLineNumbers = getCommentableLineNumbers?.(section)
|
||||
const handleAddComment = useCallback(
|
||||
(range: { lineNumber: number; startLine?: number }) => {
|
||||
if (commentableLineNumbers && !commentableLineNumbers.includes(range.lineNumber)) {
|
||||
return
|
||||
}
|
||||
setPendingComment(range)
|
||||
},
|
||||
onUpdateComment: worktreeId ? (id, body) => updateDiffComment(worktreeId, id, body) : undefined,
|
||||
pendingScrollCommentId: pendingScrollForThisSection,
|
||||
onPendingScrollConsumed: () => setScrollToDiffCommentId(null)
|
||||
})
|
||||
[commentableLineNumbers]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!modifiedEditor || !popover) {
|
||||
return
|
||||
}
|
||||
const update = (): void => {
|
||||
const lineHeight = modifiedEditor.getOption(monaco.editor.EditorOption.lineHeight)
|
||||
const top = getDiffCommentPopoverTop(modifiedEditor, popover.lineNumber, lineHeight)
|
||||
if (top == null) {
|
||||
setPopover(null)
|
||||
return
|
||||
}
|
||||
const left = getDiffCommentPopoverLeft(modifiedEditor, sectionBodyRef.current)
|
||||
setPopover((prev) =>
|
||||
prev ? { ...prev, top, left: left == null ? prev.left : left, lineHeight } : prev
|
||||
)
|
||||
}
|
||||
const scrollSub = modifiedEditor.onDidScrollChange(update)
|
||||
const contentSub = modifiedEditor.onDidContentSizeChange(update)
|
||||
const layoutSub = modifiedEditor.onDidLayoutChange(update)
|
||||
return () => {
|
||||
scrollSub.dispose()
|
||||
contentSub.dispose()
|
||||
layoutSub.dispose()
|
||||
}
|
||||
// Why: depend on popover.lineNumber (not the whole popover object) so the
|
||||
// effect doesn't re-subscribe on every top update it dispatches. The guard
|
||||
// on `popover` above handles the popover-closed case.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modifiedEditor, popover?.lineNumber])
|
||||
const fileDiff = useMemo(
|
||||
() =>
|
||||
buildPierreFileDiff({
|
||||
path: section.path,
|
||||
oldPath: section.oldPath,
|
||||
status: section.status,
|
||||
originalContent: section.originalContent,
|
||||
modifiedContent: section.modifiedContent,
|
||||
// Why: keyed by content generation so the worker AST cache survives virtualization remounts.
|
||||
cacheKey: `${section.key}:${section.contentGeneration ?? 0}`,
|
||||
parseDiffOptions: buildPierreParseDiffOptions(settings?.diffShowWhitespace)
|
||||
}),
|
||||
[
|
||||
section.path,
|
||||
section.oldPath,
|
||||
section.status,
|
||||
section.originalContent,
|
||||
section.modifiedContent,
|
||||
section.key,
|
||||
section.contentGeneration,
|
||||
settings?.diffShowWhitespace
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const diffEditor = diffEditorRef.current
|
||||
if (!diffEditor) {
|
||||
return
|
||||
}
|
||||
lineNumberOptionsSubRef.current?.dispose()
|
||||
lineNumberOptionsSubRef.current = applyDiffEditorLineNumberOptions(diffEditor, sideBySide)
|
||||
return () => {
|
||||
lineNumberOptionsSubRef.current?.dispose()
|
||||
lineNumberOptionsSubRef.current = null
|
||||
}
|
||||
}, [sideBySide])
|
||||
|
||||
const handleSubmitComment = async (body: string): Promise<void> => {
|
||||
if (!popover) {
|
||||
return
|
||||
}
|
||||
const submitted = await submitDiffSectionComment({
|
||||
addDiffComment,
|
||||
body,
|
||||
onAddLineComment,
|
||||
popover,
|
||||
section,
|
||||
worktreeId
|
||||
})
|
||||
if (submitted) {
|
||||
setPopover(null)
|
||||
}
|
||||
}
|
||||
|
||||
const { lineStats, sectionBodyHeight, useIntrinsicImageHeight, isLargeDiffLimited } =
|
||||
useDiffSectionLayoutMetrics({
|
||||
section,
|
||||
sectionHeight
|
||||
})
|
||||
|
||||
useDiffSectionFallbackCleanup({
|
||||
disposeDiffModels,
|
||||
index,
|
||||
isLargeDiffLimited,
|
||||
setSectionHeights
|
||||
})
|
||||
|
||||
const handleMount: DiffOnMount = (editor, _monaco) => {
|
||||
diffEditorRef.current = editor
|
||||
lineNumberOptionsSubRef.current?.dispose()
|
||||
lineNumberOptionsSubRef.current = applyDiffEditorLineNumberOptions(editor, sideBySide)
|
||||
const modified = editor.getModifiedEditor()
|
||||
|
||||
// Why: measuring before Monaco computes hidden unchanged regions records
|
||||
// full-file height, making virtualized combined diffs jump as rows remount.
|
||||
let diffLayoutReady = false
|
||||
let pendingHeightFrame: number | null = null
|
||||
const updateHeight = (): void => {
|
||||
const contentHeight = editor.getModifiedEditor().getContentHeight()
|
||||
setSectionHeights((prev) => {
|
||||
if (prev[index] === contentHeight) {
|
||||
return prev
|
||||
}
|
||||
return { ...prev, [index]: contentHeight }
|
||||
})
|
||||
}
|
||||
const requestHeightUpdate = (): void => {
|
||||
if (pendingHeightFrame !== null) {
|
||||
return
|
||||
}
|
||||
pendingHeightFrame = window.requestAnimationFrame(() => {
|
||||
pendingHeightFrame = null
|
||||
updateHeight()
|
||||
})
|
||||
}
|
||||
const markDiffLayoutReady = (): void => {
|
||||
diffLayoutReady = true
|
||||
requestHeightUpdate()
|
||||
}
|
||||
const contentSizeSub = modified.onDidContentSizeChange(() => {
|
||||
if (diffLayoutReady) {
|
||||
requestHeightUpdate()
|
||||
}
|
||||
})
|
||||
const diffUpdateSub = editor.onDidUpdateDiff(markDiffLayoutReady)
|
||||
if (editor.getLineChanges() !== null) {
|
||||
markDiffLayoutReady()
|
||||
}
|
||||
|
||||
setModifiedEditor(modified)
|
||||
// Why: Monaco disposes inner editors when the DiffEditor container is
|
||||
// unmounted (e.g. section collapse, tab change). Clearing the state
|
||||
// prevents decorator effects and scroll subscriptions from invoking
|
||||
// methods on a disposed editor instance, and avoids `popover` pointing
|
||||
// at a line in an editor that no longer exists.
|
||||
modified.onDidDispose(() => {
|
||||
contentSizeSub.dispose()
|
||||
diffUpdateSub.dispose()
|
||||
if (pendingHeightFrame !== null) {
|
||||
window.cancelAnimationFrame(pendingHeightFrame)
|
||||
pendingHeightFrame = null
|
||||
}
|
||||
lineNumberOptionsSubRef.current?.dispose()
|
||||
lineNumberOptionsSubRef.current = null
|
||||
diffEditorRef.current = null
|
||||
if (modifiedEditorsRef.current.get(index) === modified) {
|
||||
modifiedEditorsRef.current.delete(index)
|
||||
}
|
||||
setModifiedEditor(null)
|
||||
setPopover(null)
|
||||
})
|
||||
|
||||
if (!isEditable) {
|
||||
return
|
||||
}
|
||||
|
||||
modifiedEditorsRef.current.set(index, modified)
|
||||
const original = editor.getOriginalEditor()
|
||||
const cleanupSaveShortcut = installEditorSaveShortcut(modified.getContainerDomNode(), () =>
|
||||
handleSectionSaveRef.current(index)
|
||||
)
|
||||
const cleanupOriginalFindShortcut = installMonacoEditorFindShortcut(original)
|
||||
const cleanupModifiedFindShortcut = installMonacoEditorFindShortcut(modified)
|
||||
const modelContentSub = modified.onDidChangeModelContent(() => {
|
||||
const current = modified.getValue()
|
||||
// Why: virtualized rows unmount when scrolled away, so the draft must live in
|
||||
// section state rather than only inside the mounted editor.
|
||||
const handleEditChange = useCallback(
|
||||
(file: { contents: string }) => {
|
||||
const current = file.contents
|
||||
setSections((prev) => {
|
||||
let changed = false
|
||||
const next = prev.map((s, i) => {
|
||||
if (i !== index) {
|
||||
return s
|
||||
}
|
||||
|
||||
const savedModifiedContent =
|
||||
s.diffResult?.kind === 'text' ? s.diffResult.modifiedContent : s.modifiedContent
|
||||
const dirty = current !== savedModifiedContent
|
||||
if (s.modifiedContent === current && s.dirty === dirty) {
|
||||
return s
|
||||
}
|
||||
|
||||
changed = true
|
||||
// Why: virtualized rows unmount when scrolled away, so the draft must
|
||||
// live in section state instead of only in Monaco's mounted model.
|
||||
return {
|
||||
...s,
|
||||
modifiedContent: current,
|
||||
dirty,
|
||||
largeDiffRenderLimit: getLiveDiffSectionRenderLimit({
|
||||
section: s,
|
||||
modifiedEditor: modified,
|
||||
modifiedContent: current
|
||||
})
|
||||
}
|
||||
})
|
||||
return changed ? next : prev
|
||||
})
|
||||
})
|
||||
modified.onDidDispose(() => {
|
||||
// Why: editable diff sections own both panes' shortcut bridges and the
|
||||
// model subscription for the lifetime of this Monaco diff instance.
|
||||
cleanupSaveShortcut()
|
||||
cleanupOriginalFindShortcut()
|
||||
cleanupModifiedFindShortcut()
|
||||
modelContentSub.dispose()
|
||||
})
|
||||
}
|
||||
},
|
||||
[index, setSections]
|
||||
)
|
||||
|
||||
const handlePostRender = useCallback(
|
||||
(node: HTMLElement, phase: PostRenderPhase) => {
|
||||
if (phase === 'unmount') {
|
||||
return
|
||||
}
|
||||
const contentHeight = node.scrollHeight
|
||||
setSectionHeights((prev) =>
|
||||
prev[index] === contentHeight ? prev : { ...prev, [index]: contentHeight }
|
||||
)
|
||||
},
|
||||
[index, setSectionHeights]
|
||||
)
|
||||
|
||||
const handleSubmitComment = useCallback(
|
||||
async (body: string): Promise<void> => {
|
||||
if (!pendingComment) {
|
||||
return
|
||||
}
|
||||
const submitted = await submitDiffSectionComment({
|
||||
addDiffComment,
|
||||
body,
|
||||
onAddLineComment,
|
||||
popover: pendingComment,
|
||||
section,
|
||||
worktreeId
|
||||
})
|
||||
if (submitted) {
|
||||
setPendingComment(null)
|
||||
}
|
||||
},
|
||||
[addDiffComment, onAddLineComment, pendingComment, section, worktreeId]
|
||||
)
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
(id: string) => {
|
||||
if (worktreeId) {
|
||||
void deleteDiffComment(worktreeId, id)
|
||||
}
|
||||
},
|
||||
[deleteDiffComment, worktreeId]
|
||||
)
|
||||
|
||||
const handleUpdateComment = useMemo(
|
||||
() =>
|
||||
worktreeId
|
||||
? (id: string, body: string) => updateDiffComment(worktreeId, id, body)
|
||||
: undefined,
|
||||
[updateDiffComment, worktreeId]
|
||||
)
|
||||
|
||||
const { lineStats, sectionBodyHeight, useIntrinsicImageHeight, isLargeDiffLimited } =
|
||||
useDiffSectionLayoutMetrics({ section, sectionHeight })
|
||||
|
||||
useDiffSectionFallbackCleanup({ index, isLargeDiffLimited, setSectionHeights })
|
||||
|
||||
useEffect(() => {
|
||||
loadSection(index)
|
||||
}, [index, loadSection])
|
||||
|
||||
// Why: the save chord lives on the section root now that no editor owns a container node.
|
||||
useEffect(() => {
|
||||
const node = sectionBodyRef.current
|
||||
if (!node || !isEditable) {
|
||||
return
|
||||
}
|
||||
return installEditorSaveShortcut(node, () => void handleSectionSaveRef.current(index))
|
||||
}, [handleSectionSaveRef, index, isEditable])
|
||||
|
||||
const renderDiff = useCallback(
|
||||
() => (
|
||||
<PierreDiffSurface
|
||||
fileDiff={fileDiff}
|
||||
sideBySide={sideBySide}
|
||||
settings={settings}
|
||||
isEditable={isEditable}
|
||||
worktreeId={worktreeId ?? ''}
|
||||
filePath={section.path}
|
||||
comments={comments}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
onUpdateComment={handleUpdateComment}
|
||||
onEditChange={handleEditChange}
|
||||
onPostRender={handlePostRender}
|
||||
onAddComment={hasLineCommentAction ? handleAddComment : undefined}
|
||||
pendingComment={pendingComment}
|
||||
addCommentPlaceholder={addLineCommentPlaceholder}
|
||||
addCommentLabel={addLineCommentLabel}
|
||||
onCancelComment={() => setPendingComment(null)}
|
||||
onSubmitComment={handleSubmitComment}
|
||||
/>
|
||||
),
|
||||
[
|
||||
addLineCommentLabel,
|
||||
addLineCommentPlaceholder,
|
||||
comments,
|
||||
fileDiff,
|
||||
handleDeleteComment,
|
||||
handleAddComment,
|
||||
handleEditChange,
|
||||
handlePostRender,
|
||||
handleSubmitComment,
|
||||
handleUpdateComment,
|
||||
hasLineCommentAction,
|
||||
isEditable,
|
||||
pendingComment,
|
||||
section.path,
|
||||
settings,
|
||||
sideBySide,
|
||||
worktreeId
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={setSectionRootNode} className="border-b border-border">
|
||||
<div className="border-b border-border">
|
||||
<DiffSectionHeader
|
||||
path={section.path}
|
||||
dirty={section.dirty}
|
||||
@@ -362,25 +284,13 @@ export function DiffSectionItem({
|
||||
sectionBodyRef={sectionBodyRef}
|
||||
sectionBodyHeight={sectionBodyHeight}
|
||||
useIntrinsicImageHeight={useIntrinsicImageHeight}
|
||||
popover={popover}
|
||||
addLineCommentPlaceholder={addLineCommentPlaceholder}
|
||||
addLineCommentLabel={addLineCommentLabel}
|
||||
isBranchMode={isBranchMode}
|
||||
sideBySide={sideBySide}
|
||||
isDark={isDark}
|
||||
language={language}
|
||||
modelPathBase={modelPathBase}
|
||||
isEditable={isEditable}
|
||||
diffEditorFontSize={diffEditorFontSize}
|
||||
diffWordWrap={settings?.diffWordWrap}
|
||||
diffShowWhitespace={settings?.diffShowWhitespace}
|
||||
editorFontFamily={resolveEditorFontFamily(settings)}
|
||||
onCancelComment={() => setPopover(null)}
|
||||
onSubmitComment={handleSubmitComment}
|
||||
renderDiff={renderDiff}
|
||||
onRetrySection={retrySection}
|
||||
onLoadDeferredSection={loadDeferredSection ?? loadSection}
|
||||
onSaveLimitedDiff={() => void handleSectionSaveRef.current(index)}
|
||||
onMount={handleMount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,29 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { PostRenderPhase } from '@pierre/diffs'
|
||||
import { useAppStore } from '@/store'
|
||||
import { diffViewStateCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import { monaco } from '@/lib/monaco-setup'
|
||||
import { computeDiffEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
|
||||
import { useContextualCopySetup } from './useContextualCopySetup'
|
||||
import { diffScrollTopCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector'
|
||||
import { useDiffCommentDecorator } from '../diff-comments/useDiffCommentDecorator'
|
||||
import { DiffCommentPopover } from '../diff-comments/DiffCommentPopover'
|
||||
import {
|
||||
getDiffCommentPopoverLeft,
|
||||
getDiffCommentPopoverTop
|
||||
} from '../diff-comments/diff-comment-popover-position'
|
||||
import { applyDiffEditorLineNumberOptions } from './diff-editor-line-number-options'
|
||||
import type { DiffComment } from '../../../../shared/diff-comment-types'
|
||||
import type { DecoratedDiffComment } from '../diff-comments/decorated-diff-comment'
|
||||
import { isDiffComment } from '@/lib/diff-comment-compat'
|
||||
import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts'
|
||||
import { diffEditorScrollbarOptions } from './diff-editor-scrollbar-options'
|
||||
import { installEditorSaveShortcut } from './editor-shortcuts'
|
||||
import { LargeDiffFallback } from './LargeDiffFallback'
|
||||
import { getLargeDiffRenderLimit } from './large-diff-render-limit'
|
||||
import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecycle'
|
||||
import { getDiffViewerLargeDiffSaveAction } from './diff-viewer-large-diff-save-action'
|
||||
import type { DiffViewerProps } from './diff-viewer-props'
|
||||
import { buildDiffEditorWhitespaceOptions } from './diff-editor-whitespace-options'
|
||||
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
|
||||
import { useDiffEditorRegistration } from './diff-navigation-context'
|
||||
import { preserveDiffViewStateAcrossModelSwaps } from './diff-model-swap-view-state'
|
||||
import { monacoFindOptions } from './monaco-find-options'
|
||||
import { useDiffNavigatorRegistration, type DiffNavigator } from './diff-navigation-context'
|
||||
import { PierreDiffProviders } from './pierre-diff/PierreDiffProviders'
|
||||
import { PierreDiffSurface } from './pierre-diff/PierreDiffSurface'
|
||||
import { buildPierreFileDiff } from './pierre-diff/pierre-diff-metadata'
|
||||
import { buildPierreParseDiffOptions } from './pierre-diff/pierre-diff-options'
|
||||
import { scrollPierreDiffToLine } from './pierre-diff/pierre-diff-scroll'
|
||||
|
||||
const EMPTY_DIFF_COMMENTS: readonly DecoratedDiffComment[] = []
|
||||
|
||||
export default function DiffViewer({
|
||||
modelKey,
|
||||
originalModelKey,
|
||||
modifiedModelKey,
|
||||
originalContent,
|
||||
modifiedContent,
|
||||
language,
|
||||
filePath,
|
||||
relativePath,
|
||||
sideBySide,
|
||||
editable,
|
||||
@@ -51,12 +38,9 @@ export default function DiffViewer({
|
||||
largeDiffSaveContentAvailable
|
||||
}: DiffViewerProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
|
||||
const addDiffComment = useAppStore((s) => s.addDiffComment)
|
||||
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
|
||||
const updateDiffComment = useAppStore((s) => s.updateDiffComment)
|
||||
const scrollToDiffCommentId = useAppStore((s) => s.scrollToDiffCommentId)
|
||||
const setScrollToDiffCommentId = useAppStore((s) => s.setScrollToDiffCommentId)
|
||||
// Why: subscribe to the raw array so selector identity only changes when this worktree's comments change; filtering happens below.
|
||||
const allDiffComments = useAppStore((s): DiffComment[] | undefined =>
|
||||
selectWorktreeDiffComments(s, worktreeId)
|
||||
@@ -65,23 +49,16 @@ export default function DiffViewer({
|
||||
() => (allDiffComments ?? []).filter((c) => c.filePath === relativePath && isDiffComment(c)),
|
||||
[allDiffComments, relativePath]
|
||||
)
|
||||
const terminalFontSize = settings?.terminalFontSize ?? 13
|
||||
const diffEditorFontSize = computeDiffEditorFontSize(terminalFontSize, editorFontZoomLevel)
|
||||
const isDark =
|
||||
settings?.theme === 'dark' ||
|
||||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const comments = useMemo(
|
||||
() => (worktreeId ? diffComments : EMPTY_DIFF_COMMENTS),
|
||||
[diffComments, worktreeId]
|
||||
)
|
||||
|
||||
const diffEditorRef = useRef<editor.IStandaloneDiffEditor | null>(null)
|
||||
const { registerDiffEditor, unregisterDiffEditor } = useDiffEditorRegistration()
|
||||
const diffBodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const lineNumberOptionsSubRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const [modifiedEditor, setModifiedEditor] = useState<editor.ICodeEditor | null>(null)
|
||||
const [popover, setPopover] = useState<{
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const pierreHostRef = useRef<HTMLElement | null>(null)
|
||||
const [pendingComment, setPendingComment] = useState<{
|
||||
lineNumber: number
|
||||
startLine?: number
|
||||
top: number
|
||||
left?: number
|
||||
lineHeight: number
|
||||
} | null>(null)
|
||||
|
||||
const renderLimit = useMemo(
|
||||
@@ -90,308 +67,164 @@ export default function DiffViewer({
|
||||
)
|
||||
const hasLineCommentAction = Boolean(worktreeId || onAddLineComment)
|
||||
|
||||
// Why: only forward the pending scroll id when this viewer owns the comment, else unrelated viewers race to ack it.
|
||||
const pendingScrollForThisViewer = useMemo(() => {
|
||||
if (!worktreeId || !scrollToDiffCommentId) {
|
||||
return null
|
||||
}
|
||||
return diffComments.some((c) => c.id === scrollToDiffCommentId) ? scrollToDiffCommentId : null
|
||||
}, [scrollToDiffCommentId, diffComments, worktreeId])
|
||||
|
||||
// Why: gate the decorator on a comment target; updateDiffComment is only wired for local diffs (worktreeId present).
|
||||
useDiffCommentDecorator({
|
||||
editor: hasLineCommentAction ? modifiedEditor : null,
|
||||
monacoModelIdentity: modifiedModelKey ?? modelKey,
|
||||
filePath: relativePath,
|
||||
worktreeId: worktreeId ?? '',
|
||||
comments: worktreeId ? diffComments : [],
|
||||
commentableLineNumbers,
|
||||
addButtonLabel: addLineCommentLabel,
|
||||
onAddCommentClick: ({ lineNumber, startLine, top }) =>
|
||||
setPopover({
|
||||
lineNumber,
|
||||
startLine,
|
||||
top,
|
||||
left: modifiedEditor
|
||||
? (getDiffCommentPopoverLeft(modifiedEditor, diffBodyRef.current) ?? undefined)
|
||||
: undefined,
|
||||
lineHeight: modifiedEditor?.getOption(monaco.editor.EditorOption.lineHeight) ?? 0
|
||||
const fileDiff = useMemo(
|
||||
() =>
|
||||
buildPierreFileDiff({
|
||||
path: relativePath,
|
||||
status: 'modified',
|
||||
originalContent,
|
||||
modifiedContent,
|
||||
cacheKey: modelKey,
|
||||
parseDiffOptions: buildPierreParseDiffOptions(settings?.diffShowWhitespace)
|
||||
}),
|
||||
onDeleteComment: (id) => {
|
||||
if (worktreeId) {
|
||||
void deleteDiffComment(worktreeId, id)
|
||||
}
|
||||
},
|
||||
onUpdateComment: worktreeId ? (id, body) => updateDiffComment(worktreeId, id, body) : undefined,
|
||||
pendingScrollCommentId: pendingScrollForThisViewer,
|
||||
onPendingScrollConsumed: () => setScrollToDiffCommentId(null)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!modifiedEditor || !popover) {
|
||||
return
|
||||
}
|
||||
const update = (): void => {
|
||||
const lineHeight = modifiedEditor.getOption(monaco.editor.EditorOption.lineHeight)
|
||||
const top = getDiffCommentPopoverTop(modifiedEditor, popover.lineNumber, lineHeight)
|
||||
if (top == null) {
|
||||
setPopover(null)
|
||||
return
|
||||
}
|
||||
const left = getDiffCommentPopoverLeft(modifiedEditor, diffBodyRef.current)
|
||||
setPopover((prev) =>
|
||||
prev ? { ...prev, top, left: left == null ? prev.left : left, lineHeight } : prev
|
||||
)
|
||||
}
|
||||
const scrollSub = modifiedEditor.onDidScrollChange(update)
|
||||
const contentSub = modifiedEditor.onDidContentSizeChange(update)
|
||||
const layoutSub = modifiedEditor.onDidLayoutChange(update)
|
||||
return () => {
|
||||
scrollSub.dispose()
|
||||
contentSub.dispose()
|
||||
layoutSub.dispose()
|
||||
}
|
||||
// Why: depend on popover.lineNumber (not the whole object) so the effect doesn't re-subscribe on every top update.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modifiedEditor, popover?.lineNumber])
|
||||
|
||||
// Why: center the first diff from a dedicated effect (not handleMount) so it runs after the decorator's view zones, which would otherwise shift content downward.
|
||||
const didAutoScrollFirstDiffRef = useRef(false)
|
||||
const didAutoScrollModelKeyRef = useRef(modelKey)
|
||||
useEffect(() => {
|
||||
if (didAutoScrollModelKeyRef.current !== modelKey) {
|
||||
didAutoScrollModelKeyRef.current = modelKey
|
||||
// Why: reset the per-modelKey one-shot here before the first-diff guard runs for the new file.
|
||||
didAutoScrollFirstDiffRef.current = false
|
||||
}
|
||||
const diffEditor = diffEditorRef.current
|
||||
if (!diffEditor || !modifiedEditor) {
|
||||
return
|
||||
}
|
||||
if (didAutoScrollFirstDiffRef.current) {
|
||||
return
|
||||
}
|
||||
if (diffViewStateCache.get(modelKey)) {
|
||||
return
|
||||
}
|
||||
if (pendingScrollForThisViewer) {
|
||||
// Why: decorator owns this scroll, so set the one-shot flag; else we'd re-run and overwrite it when pendingScroll flips back to null.
|
||||
didAutoScrollFirstDiffRef.current = true
|
||||
return
|
||||
}
|
||||
let rafId: number | null = null
|
||||
const run = (): void => {
|
||||
if (didAutoScrollFirstDiffRef.current) {
|
||||
return
|
||||
}
|
||||
const changes = diffEditor.getLineChanges()
|
||||
if (!changes || changes.length === 0) {
|
||||
return
|
||||
}
|
||||
const line = Math.max(1, changes[0].modifiedStartLineNumber)
|
||||
// Defer one frame so view zones are laid out before measuring; cancel any earlier rAF to avoid a redundant scroll.
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null
|
||||
if (didAutoScrollFirstDiffRef.current || !modifiedEditor.getModel()) {
|
||||
return
|
||||
}
|
||||
const top = modifiedEditor.getTopForLineNumber(line, true)
|
||||
const editorHeight = modifiedEditor.getLayoutInfo().height
|
||||
modifiedEditor.setPosition({ lineNumber: line, column: 1 })
|
||||
modifiedEditor.setScrollTop(Math.max(0, top - editorHeight / 2))
|
||||
didAutoScrollFirstDiffRef.current = true
|
||||
})
|
||||
}
|
||||
// Run now if the diff is ready; otherwise onDidUpdateDiff fires once the computation lands.
|
||||
if (diffEditor.getLineChanges()) {
|
||||
run()
|
||||
}
|
||||
const sub = diffEditor.onDidUpdateDiff(() => run())
|
||||
return () => {
|
||||
sub.dispose()
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
}
|
||||
}, [modifiedEditor, modelKey, pendingScrollForThisViewer])
|
||||
|
||||
const handleEnterLargeDiffFallback = useCallback(() => {
|
||||
// Why: on fallback transition, drop stale Monaco refs so decorators/save handlers don't talk to disposed UI.
|
||||
lineNumberOptionsSubRef.current?.dispose()
|
||||
lineNumberOptionsSubRef.current = null
|
||||
// Why: capture before nulling so we unregister the exact instance (identity guard no-ops a stale dispose).
|
||||
const fallenBackEditor = diffEditorRef.current
|
||||
diffEditorRef.current = null
|
||||
if (fallenBackEditor) {
|
||||
unregisterDiffEditor(fallenBackEditor)
|
||||
}
|
||||
setModifiedEditor(null)
|
||||
setPopover(null)
|
||||
}, [unregisterDiffEditor])
|
||||
|
||||
const handleSubmitComment = async (body: string): Promise<void> => {
|
||||
if (!popover) {
|
||||
return
|
||||
}
|
||||
if (onAddLineComment) {
|
||||
const ok = await onAddLineComment({
|
||||
lineNumber: popover.lineNumber,
|
||||
startLine: popover.startLine,
|
||||
body
|
||||
})
|
||||
if (ok) {
|
||||
setPopover(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
// Why: await persistence — a null result (failed save) keeps the popover open for retry instead of losing the draft.
|
||||
const result = await addDiffComment({
|
||||
worktreeId,
|
||||
filePath: relativePath,
|
||||
source: 'diff',
|
||||
startLine: popover.startLine,
|
||||
lineNumber: popover.lineNumber,
|
||||
body,
|
||||
side: 'modified'
|
||||
})
|
||||
if (result) {
|
||||
setPopover(null)
|
||||
} else {
|
||||
console.error('Failed to add diff comment — draft preserved')
|
||||
}
|
||||
}
|
||||
|
||||
// Keep refs to latest callbacks so the mounted editor always calls current versions
|
||||
const onSaveRef = useRef(onSave)
|
||||
onSaveRef.current = onSave
|
||||
const onContentChangeRef = useRef(onContentChange)
|
||||
onContentChangeRef.current = onContentChange
|
||||
|
||||
const { setupCopy, toastNode } = useContextualCopySetup()
|
||||
|
||||
const propsRef = useRef({ relativePath, language, onSave })
|
||||
propsRef.current = { relativePath, language, onSave }
|
||||
const currentDiffModelPaths = useDiffViewerLargeDiffLifecycle({
|
||||
limited: renderLimit.limited,
|
||||
modelKey,
|
||||
originalModelKey,
|
||||
modifiedModelKey,
|
||||
diffEditorRef,
|
||||
onEnterFallback: handleEnterLargeDiffFallback
|
||||
})
|
||||
|
||||
const handleMount: DiffOnMount = useCallback(
|
||||
(diffEditor, monaco) => {
|
||||
diffEditorRef.current = diffEditor
|
||||
registerDiffEditor(diffEditor)
|
||||
lineNumberOptionsSubRef.current?.dispose()
|
||||
lineNumberOptionsSubRef.current = applyDiffEditorLineNumberOptions(diffEditor, sideBySide)
|
||||
|
||||
const originalEditor = diffEditor.getOriginalEditor()
|
||||
const modifiedEditor = diffEditor.getModifiedEditor()
|
||||
diffEditor.onDidDispose(preserveDiffViewStateAcrossModelSwaps(diffEditor).dispose)
|
||||
|
||||
setupCopy(originalEditor, monaco, filePath, propsRef)
|
||||
setupCopy(modifiedEditor, monaco, filePath, propsRef)
|
||||
setModifiedEditor(modifiedEditor)
|
||||
|
||||
// Why: restore full diff view state (not just scrollTop) so cursor/selection stay consistent across both panes.
|
||||
const savedViewState = diffViewStateCache.get(modelKey)
|
||||
if (savedViewState) {
|
||||
requestAnimationFrame(() => diffEditor.restoreViewState(savedViewState))
|
||||
}
|
||||
// Auto-scroll to first diff lives in a separate effect below so it sequences after the decorator's view zones land.
|
||||
|
||||
if (editable) {
|
||||
const cleanupSaveShortcut = installEditorSaveShortcut(
|
||||
modifiedEditor.getContainerDomNode(),
|
||||
() => {
|
||||
onSaveRef.current?.(modifiedEditor.getValue())
|
||||
}
|
||||
)
|
||||
const cleanupOriginalFindShortcut = installMonacoEditorFindShortcut(originalEditor)
|
||||
const cleanupModifiedFindShortcut = installMonacoEditorFindShortcut(modifiedEditor)
|
||||
|
||||
// Track changes
|
||||
const modelContentSub = modifiedEditor.onDidChangeModelContent(() => {
|
||||
onContentChangeRef.current?.(modifiedEditor.getValue())
|
||||
})
|
||||
modifiedEditor.onDidDispose(() => {
|
||||
// Why: this diff instance owns both panes' shortcut bridges + the model sub, so dispose them with it.
|
||||
cleanupSaveShortcut()
|
||||
cleanupOriginalFindShortcut()
|
||||
cleanupModifiedFindShortcut()
|
||||
modelContentSub.dispose()
|
||||
})
|
||||
|
||||
modifiedEditor.focus()
|
||||
} else {
|
||||
diffEditor.focus()
|
||||
}
|
||||
|
||||
// Why: clear modifiedEditor on dispose so decorator effects don't call into a disposed Monaco editor.
|
||||
diffEditor.onDidDispose(() => {
|
||||
lineNumberOptionsSubRef.current?.dispose()
|
||||
lineNumberOptionsSubRef.current = null
|
||||
diffEditorRef.current = null
|
||||
unregisterDiffEditor(diffEditor)
|
||||
setModifiedEditor(null)
|
||||
setPopover(null)
|
||||
})
|
||||
},
|
||||
[editable, setupCopy, modelKey, filePath, sideBySide, registerDiffEditor, unregisterDiffEditor]
|
||||
[relativePath, originalContent, modifiedContent, modelKey, settings?.diffShowWhitespace]
|
||||
)
|
||||
|
||||
// Why: snapshot view state on deactivation (layoutEffect cleanup fires before unmount), not on scroll.
|
||||
const { registerDiffNavigator, unregisterDiffNavigator } = useDiffNavigatorRegistration()
|
||||
const changeLines = useMemo(() => fileDiff.hunks.map((hunk) => hunk.additionStart), [fileDiff])
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container || renderLimit.limited) {
|
||||
return
|
||||
}
|
||||
const navigator: DiffNavigator = {
|
||||
changeLines,
|
||||
container,
|
||||
scrollToChange: ({ lineNumber, hunkIndex, hunkCount }) => {
|
||||
scrollPierreDiffToLine({
|
||||
host: pierreHostRef.current,
|
||||
container,
|
||||
lineNumber,
|
||||
hunkIndex,
|
||||
hunkCount
|
||||
})
|
||||
}
|
||||
}
|
||||
registerDiffNavigator(navigator)
|
||||
return () => unregisterDiffNavigator(navigator)
|
||||
}, [changeLines, registerDiffNavigator, renderLimit.limited, unregisterDiffNavigator])
|
||||
|
||||
const handlePostRender = useCallback((node: HTMLElement, phase: PostRenderPhase) => {
|
||||
pierreHostRef.current = phase === 'unmount' ? null : node
|
||||
}, [])
|
||||
|
||||
// Why: restore scroll after the first paint so Pierre has laid out its rows.
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
const saved = diffScrollTopCache.get(modelKey)
|
||||
if (!container || saved === undefined) {
|
||||
return
|
||||
}
|
||||
const frame = requestAnimationFrame(() => {
|
||||
container.scrollTop = saved
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [modelKey])
|
||||
|
||||
// Why: snapshot on deactivation (layout-effect cleanup runs before unmount), not on every scroll event.
|
||||
useLayoutEffect(() => {
|
||||
// Why: capture the node now — the same div serves this modelKey for the
|
||||
// effect's whole life, and reading the ref at cleanup races unmount.
|
||||
const container = scrollContainerRef.current
|
||||
return () => {
|
||||
const de = diffEditorRef.current
|
||||
if (de) {
|
||||
const currentViewState = de.saveViewState()
|
||||
if (currentViewState) {
|
||||
setWithLRU(diffViewStateCache, modelKey, currentViewState)
|
||||
}
|
||||
if (container) {
|
||||
setWithLRU(diffScrollTopCache, modelKey, container.scrollTop)
|
||||
}
|
||||
}
|
||||
}, [modelKey])
|
||||
|
||||
const onSaveRef = useRef(onSave)
|
||||
onSaveRef.current = onSave
|
||||
const modifiedContentRef = useRef(modifiedContent)
|
||||
modifiedContentRef.current = modifiedContent
|
||||
|
||||
useEffect(() => {
|
||||
const diffEditor = diffEditorRef.current
|
||||
if (!diffEditor) {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container || !editable) {
|
||||
return
|
||||
}
|
||||
lineNumberOptionsSubRef.current?.dispose()
|
||||
lineNumberOptionsSubRef.current = applyDiffEditorLineNumberOptions(diffEditor, sideBySide)
|
||||
return () => {
|
||||
lineNumberOptionsSubRef.current?.dispose()
|
||||
lineNumberOptionsSubRef.current = null
|
||||
}
|
||||
}, [sideBySide])
|
||||
return installEditorSaveShortcut(container, () => {
|
||||
onSaveRef.current?.(modifiedContentRef.current)
|
||||
})
|
||||
}, [editable])
|
||||
|
||||
const handleEditChange = useCallback(
|
||||
(file: { contents: string }) => {
|
||||
modifiedContentRef.current = file.contents
|
||||
onContentChange?.(file.contents)
|
||||
},
|
||||
[onContentChange]
|
||||
)
|
||||
|
||||
const handleAddComment = useCallback(
|
||||
(range: { lineNumber: number; startLine?: number }) => {
|
||||
if (commentableLineNumbers && !commentableLineNumbers.includes(range.lineNumber)) {
|
||||
return
|
||||
}
|
||||
setPendingComment(range)
|
||||
},
|
||||
[commentableLineNumbers]
|
||||
)
|
||||
|
||||
const handleSubmitComment = useCallback(
|
||||
async (body: string): Promise<void> => {
|
||||
if (!pendingComment) {
|
||||
return
|
||||
}
|
||||
if (onAddLineComment) {
|
||||
const ok = await onAddLineComment({
|
||||
lineNumber: pendingComment.lineNumber,
|
||||
startLine: pendingComment.startLine,
|
||||
body
|
||||
})
|
||||
if (ok) {
|
||||
setPendingComment(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
// Why: await persistence — a null result (failed save) keeps the draft open for retry.
|
||||
const result = await addDiffComment({
|
||||
worktreeId,
|
||||
filePath: relativePath,
|
||||
source: 'diff',
|
||||
startLine: pendingComment.startLine,
|
||||
lineNumber: pendingComment.lineNumber,
|
||||
body,
|
||||
side: 'modified'
|
||||
})
|
||||
if (result) {
|
||||
setPendingComment(null)
|
||||
} else {
|
||||
console.error('Failed to add diff comment — draft preserved')
|
||||
}
|
||||
},
|
||||
[addDiffComment, onAddLineComment, pendingComment, relativePath, worktreeId]
|
||||
)
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
(id: string) => {
|
||||
if (worktreeId) {
|
||||
void deleteDiffComment(worktreeId, id)
|
||||
}
|
||||
},
|
||||
[deleteDiffComment, worktreeId]
|
||||
)
|
||||
|
||||
const handleUpdateComment = useMemo(
|
||||
() =>
|
||||
worktreeId
|
||||
? (id: string, body: string) => updateDiffComment(worktreeId, id, body)
|
||||
: undefined,
|
||||
[updateDiffComment, worktreeId]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div ref={diffBodyRef} className="flex-1 min-h-0 relative">
|
||||
{popover && hasLineCommentAction && !renderLimit.limited && (
|
||||
<DiffCommentPopover
|
||||
key={popover.lineNumber}
|
||||
lineNumber={popover.lineNumber}
|
||||
startLine={popover.startLine}
|
||||
top={popover.top}
|
||||
left={popover.left}
|
||||
lineHeight={popover.lineHeight}
|
||||
placeholder={addLineCommentPlaceholder}
|
||||
submitLabel={addLineCommentLabel}
|
||||
submittingLabel="Posting…"
|
||||
onCancel={() => setPopover(null)}
|
||||
onSubmit={handleSubmitComment}
|
||||
/>
|
||||
)}
|
||||
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-auto scrollbar-editor">
|
||||
{renderLimit.limited ? (
|
||||
<LargeDiffFallback
|
||||
filePath={relativePath}
|
||||
@@ -404,40 +237,29 @@ export default function DiffViewer({
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
original={originalContent}
|
||||
modified={modifiedContent}
|
||||
theme={isDark ? 'vs-dark' : 'vs'}
|
||||
onMount={handleMount}
|
||||
// Why: a file can have multiple live diff tabs, so key models off tab identity (not file path) to avoid cross-tab reuse.
|
||||
// Why: Changes mode rotates only the original-side model after HEAD moves, preserving the modified side's undo stack.
|
||||
originalModelPath={currentDiffModelPaths.originalModelPath}
|
||||
modifiedModelPath={currentDiffModelPaths.modifiedModelPath}
|
||||
keepCurrentOriginalModel
|
||||
keepCurrentModifiedModel
|
||||
options={{
|
||||
readOnly: !editable,
|
||||
originalEditable: false,
|
||||
renderSideBySide: sideBySide,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: diffEditorFontSize,
|
||||
fontFamily: resolveEditorFontFamily(settings),
|
||||
lineNumbers: 'on',
|
||||
...buildDiffEditorWordWrapOptions(settings?.diffWordWrap),
|
||||
...buildDiffEditorWhitespaceOptions(settings?.diffShowWhitespace),
|
||||
automaticLayout: true,
|
||||
renderOverviewRuler: true,
|
||||
scrollbar: diffEditorScrollbarOptions,
|
||||
padding: { top: 0 },
|
||||
find: monacoFindOptions
|
||||
}}
|
||||
/>
|
||||
<PierreDiffProviders>
|
||||
<PierreDiffSurface
|
||||
fileDiff={fileDiff}
|
||||
sideBySide={sideBySide}
|
||||
settings={settings}
|
||||
isEditable={Boolean(editable)}
|
||||
worktreeId={worktreeId ?? ''}
|
||||
filePath={relativePath}
|
||||
comments={comments}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
onUpdateComment={handleUpdateComment}
|
||||
onEditChange={handleEditChange}
|
||||
onPostRender={handlePostRender}
|
||||
onAddComment={hasLineCommentAction ? handleAddComment : undefined}
|
||||
pendingComment={pendingComment}
|
||||
addCommentPlaceholder={addLineCommentPlaceholder}
|
||||
addCommentLabel={addLineCommentLabel}
|
||||
onCancelComment={() => setPendingComment(null)}
|
||||
onSubmitComment={handleSubmitComment}
|
||||
/>
|
||||
</PierreDiffProviders>
|
||||
)}
|
||||
</div>
|
||||
{toastNode}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
diffViewStateCache,
|
||||
diffScrollTopCache,
|
||||
editorSelectionCache,
|
||||
pdfViewPositionCache,
|
||||
scrollTopCache
|
||||
@@ -141,7 +141,7 @@ function buildScenario(): {
|
||||
beforeEach(() => {
|
||||
scrollTopCache.clear()
|
||||
editorSelectionCache.clear()
|
||||
diffViewStateCache.clear()
|
||||
diffScrollTopCache.clear()
|
||||
pdfViewPositionCache.clear()
|
||||
})
|
||||
|
||||
@@ -214,15 +214,15 @@ describe('disposeClosedEditorTabs', () => {
|
||||
})
|
||||
|
||||
it('drops diff view state and preview scroll entries for closed diff tabs', () => {
|
||||
diffViewStateCache.set('tab-1', {} as never)
|
||||
diffViewStateCache.set('tab-1::pane-1', {} as never)
|
||||
diffViewStateCache.set('tab-10', {} as never)
|
||||
diffScrollTopCache.set('tab-1', {} as never)
|
||||
diffScrollTopCache.set('tab-1::pane-1', {} as never)
|
||||
diffScrollTopCache.set('tab-10', {} as never)
|
||||
scrollTopCache.set('tab-1:preview', 5)
|
||||
scrollTopCache.set('tab-1::pane-1', 6)
|
||||
|
||||
disposeClosedEditorTabs(createRegistry([]), [diffTab('tab-1')])
|
||||
|
||||
expect([...diffViewStateCache.keys()]).toEqual(['tab-10'])
|
||||
expect([...diffScrollTopCache.keys()]).toEqual(['tab-10'])
|
||||
expect(scrollTopCache.size).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import {
|
||||
editorSelectionCache,
|
||||
diffViewStateCache,
|
||||
diffScrollTopCache,
|
||||
pdfViewPositionCache,
|
||||
scrollTopCache
|
||||
} from '@/lib/scroll-cache'
|
||||
@@ -33,7 +33,7 @@ export function disposeClosedEditorTabs(
|
||||
const diffModelPathPrefixes: string[] = []
|
||||
const scrollTopOwners: string[] = []
|
||||
const editorSelectionOwners: string[] = []
|
||||
const diffViewStateOwners: string[] = []
|
||||
const diffScrollTopOwners: string[] = []
|
||||
const closedPdfFilePaths: string[] = []
|
||||
|
||||
for (const closedFile of closedFiles) {
|
||||
@@ -65,8 +65,8 @@ export function disposeClosedEditorTabs(
|
||||
const { originalModelPathPrefix, modifiedModelPathPrefix } =
|
||||
getDiffViewerMonacoModelPathPrefixes(closedFile.id)
|
||||
diffModelPathPrefixes.push(originalModelPathPrefix, modifiedModelPathPrefix)
|
||||
diffViewStateCache.delete(closedFile.id)
|
||||
diffViewStateOwners.push(closedFile.id)
|
||||
diffScrollTopCache.delete(closedFile.id)
|
||||
diffScrollTopOwners.push(closedFile.id)
|
||||
scrollTopCache.delete(`${closedFile.id}:preview`)
|
||||
scrollTopOwners.push(closedFile.id)
|
||||
break
|
||||
@@ -81,6 +81,6 @@ export function disposeClosedEditorTabs(
|
||||
disposeUnattachedMonacoModelsByPathPrefixes(monacoRegistry, diffModelPathPrefixes)
|
||||
deletePaneScopedCacheEntries(scrollTopCache, scrollTopOwners)
|
||||
deletePaneScopedCacheEntries(editorSelectionCache, editorSelectionOwners)
|
||||
deletePaneScopedCacheEntries(diffViewStateCache, diffViewStateOwners)
|
||||
deletePaneScopedCacheEntries(diffScrollTopCache, diffScrollTopOwners)
|
||||
sweepClosedPdfViewPositions(pdfViewPositionCache, closedPdfFilePaths)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { useCombinedDiffNotesActions } from './review-controls/use-combined-diff-notes-actions'
|
||||
import { useCombinedDiffSectionActions } from './review-controls/use-combined-diff-section-actions'
|
||||
import { useCombinedDiffViewPreferences } from './review-controls/use-combined-diff-view-preferences'
|
||||
import { PierreDiffProviders } from '../pierre-diff/PierreDiffProviders'
|
||||
|
||||
export default function CombinedDiffViewer({
|
||||
file,
|
||||
@@ -64,9 +65,6 @@ export default function CombinedDiffViewer({
|
||||
)
|
||||
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[file.worktreeId])
|
||||
const canOpenWorkspaceFileBrowserForPath = useWorkspaceFileBrowserActionPredicate(file.worktreeId)
|
||||
const isDark =
|
||||
settings?.theme === 'dark' ||
|
||||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const [sections, setSections] = useState<DiffSection[]>([])
|
||||
const [sectionHeights, setSectionHeights] = useState<Record<number, number>>({})
|
||||
@@ -196,21 +194,20 @@ export default function CombinedDiffViewer({
|
||||
shouldAutoReloadFromGitStatus: entrySet.shouldAutoReloadFromGitStatus,
|
||||
treeMode: entrySet.treeMode
|
||||
})
|
||||
const { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview } =
|
||||
useCombinedDiffSectionActions({
|
||||
activeGroupId,
|
||||
branchCompare: entrySet.branchCompare,
|
||||
canOpenWorkspaceFileBrowserForPath,
|
||||
commitCompare: entrySet.commitCompare,
|
||||
file,
|
||||
isAllMode: entrySet.isAllMode,
|
||||
isBranchMode: entrySet.isBranchMode,
|
||||
isCommitMode: entrySet.isCommitMode,
|
||||
sections,
|
||||
sectionsRef: registry.sectionsRef,
|
||||
setSectionHeights,
|
||||
setSections
|
||||
})
|
||||
const { handleSectionSaveRef, openSection, openSectionPreview } = useCombinedDiffSectionActions({
|
||||
activeGroupId,
|
||||
branchCompare: entrySet.branchCompare,
|
||||
canOpenWorkspaceFileBrowserForPath,
|
||||
commitCompare: entrySet.commitCompare,
|
||||
file,
|
||||
isAllMode: entrySet.isAllMode,
|
||||
isBranchMode: entrySet.isBranchMode,
|
||||
isCommitMode: entrySet.isCommitMode,
|
||||
sections,
|
||||
sectionsRef: registry.sectionsRef,
|
||||
setSectionHeights,
|
||||
setSections
|
||||
})
|
||||
|
||||
useCombinedDiffViewPersist({
|
||||
combinedGitStatusSignature,
|
||||
@@ -313,7 +310,7 @@ export default function CombinedDiffViewer({
|
||||
const allSectionsCollapsed = sectionRowKeys.allSectionsCollapsed
|
||||
|
||||
return (
|
||||
<>
|
||||
<PierreDiffProviders>
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<CombinedDiffToolbar
|
||||
activeGroupId={activeGroupId}
|
||||
@@ -365,11 +362,9 @@ export default function CombinedDiffViewer({
|
||||
isAllMode={entrySet.isAllMode}
|
||||
isBranchMode={entrySet.isBranchMode}
|
||||
isCommitMode={entrySet.isCommitMode}
|
||||
isDark={isDark}
|
||||
loadSection={loadSection}
|
||||
loadDeferredSection={loadDeferredSection}
|
||||
markDirectScrollInput={markDirectScrollInput}
|
||||
modifiedEditorsRef={modifiedEditorsRef}
|
||||
onScrollbarPointerDown={handleScrollbarPointerDown}
|
||||
openSection={openSection}
|
||||
openSectionPreview={openSectionPreview}
|
||||
@@ -395,6 +390,6 @@ export default function CombinedDiffViewer({
|
||||
open={notes.clearNotesDialogVisible}
|
||||
setOpen={notes.setClearNotesDialogOpen}
|
||||
/>
|
||||
</>
|
||||
</PierreDiffProviders>
|
||||
)
|
||||
}
|
||||
|
||||
+12
-7
@@ -15,8 +15,10 @@ async function flushMicrotasks(): Promise<void> {
|
||||
}
|
||||
|
||||
describe('combined diff load scheduler', () => {
|
||||
it('defaults to serial section loads', async () => {
|
||||
const blockers = [deferred(), deferred()]
|
||||
it('defaults to loading several sections in parallel', async () => {
|
||||
// Why: the renderer highlights in a worker pool now, so fetches are no
|
||||
// longer serialized behind per-section editor construction.
|
||||
const blockers = [deferred(), deferred(), deferred(), deferred(), deferred()]
|
||||
const started: number[] = []
|
||||
const scheduler = createCombinedDiffLoadScheduler({
|
||||
schedule: (callback) => callback(),
|
||||
@@ -26,15 +28,18 @@ describe('combined diff load scheduler', () => {
|
||||
}
|
||||
})
|
||||
|
||||
scheduler.request(1)
|
||||
scheduler.request(2)
|
||||
expect(started).toEqual([1])
|
||||
for (let index = 1; index <= 5; index += 1) {
|
||||
scheduler.request(index)
|
||||
}
|
||||
expect(started).toEqual([1, 2, 3, 4])
|
||||
|
||||
blockers[0]!.resolve()
|
||||
await flushMicrotasks()
|
||||
expect(started).toEqual([1, 2])
|
||||
expect(started).toEqual([1, 2, 3, 4, 5])
|
||||
|
||||
blockers[1]!.resolve()
|
||||
for (const blocker of blockers.slice(1)) {
|
||||
blocker.resolve()
|
||||
}
|
||||
await flushMicrotasks()
|
||||
})
|
||||
|
||||
|
||||
+3
-3
@@ -8,9 +8,9 @@ export type CombinedDiffLoadScheduler = {
|
||||
export function createCombinedDiffLoadScheduler({
|
||||
loadSection,
|
||||
schedule = (callback) => queueMicrotask(callback),
|
||||
// Why: a settled section usually mounts a Monaco DiffEditor. Serializing by
|
||||
// default keeps large lockfile-style diffs from stacking render work.
|
||||
maxConcurrent = 1
|
||||
// Why: sections now render through Pierre, which highlights in a worker pool
|
||||
// instead of constructing an editor, so fetches no longer need serializing.
|
||||
maxConcurrent = 4
|
||||
}: {
|
||||
loadSection: (index: number) => Promise<void>
|
||||
schedule?: (callback: () => void) => void
|
||||
|
||||
+4
-7
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type React from 'react'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import { useAppStore } from '@/store'
|
||||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { joinPath } from '@/lib/path'
|
||||
@@ -19,7 +18,6 @@ import type { DiffSectionItemProps } from '../../diff-section-item-props'
|
||||
|
||||
export type CombinedDiffSectionActions = {
|
||||
handleSectionSaveRef: DiffSectionItemProps['handleSectionSaveRef']
|
||||
modifiedEditorsRef: DiffSectionItemProps['modifiedEditorsRef']
|
||||
openSection: (index: number) => void
|
||||
openSectionPreview: (section: DiffSection) => void
|
||||
}
|
||||
@@ -54,7 +52,6 @@ export function useCombinedDiffSectionActions({
|
||||
const openFile = useAppStore((s) => s.openFile)
|
||||
const openBranchDiff = useAppStore((s) => s.openBranchDiff)
|
||||
const openCommitDiff = useAppStore((s) => s.openCommitDiff)
|
||||
const modifiedEditorsRef = useRef<Map<number, monacoEditor.IStandaloneCodeEditor>>(new Map())
|
||||
|
||||
const openSection = useCallback(
|
||||
(index: number) => {
|
||||
@@ -158,13 +155,13 @@ export function useCombinedDiffSectionActions({
|
||||
if (!section) {
|
||||
return
|
||||
}
|
||||
const modifiedEditor = modifiedEditorsRef.current.get(index)
|
||||
if (!modifiedEditor && !section.dirty) {
|
||||
if (!section.dirty) {
|
||||
return
|
||||
}
|
||||
|
||||
const sectionKey = section.key
|
||||
const content = modifiedEditor?.getValue() ?? section.modifiedContent
|
||||
// Why: the renderer mirrors every keystroke into section state, so it is the only draft source.
|
||||
const content = section.modifiedContent
|
||||
const absolutePath = joinPath(file.filePath, section.path)
|
||||
try {
|
||||
const state = useAppStore.getState()
|
||||
@@ -242,5 +239,5 @@ export function useCombinedDiffSectionActions({
|
||||
const handleSectionSaveRef = useRef(handleSectionSave)
|
||||
handleSectionSaveRef.current = handleSectionSave
|
||||
|
||||
return { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview }
|
||||
return { handleSectionSaveRef, openSection, openSectionPreview }
|
||||
}
|
||||
|
||||
-6
@@ -20,11 +20,9 @@ export function CombinedDiffSectionList({
|
||||
isAllMode,
|
||||
isBranchMode,
|
||||
isCommitMode,
|
||||
isDark,
|
||||
loadSection,
|
||||
loadDeferredSection,
|
||||
markDirectScrollInput,
|
||||
modifiedEditorsRef,
|
||||
onScrollbarPointerDown,
|
||||
openSection,
|
||||
openSectionPreview,
|
||||
@@ -49,11 +47,9 @@ export function CombinedDiffSectionList({
|
||||
isAllMode: boolean
|
||||
isBranchMode: boolean
|
||||
isCommitMode: boolean
|
||||
isDark: boolean
|
||||
loadSection: (index: number) => void
|
||||
loadDeferredSection: (index: number) => void
|
||||
markDirectScrollInput: () => void
|
||||
modifiedEditorsRef: DiffSectionItemProps['modifiedEditorsRef']
|
||||
onScrollbarPointerDown: (event: React.PointerEvent<HTMLDivElement>) => void
|
||||
openSection: (index: number) => void
|
||||
openSectionPreview: (section: DiffSection) => void
|
||||
@@ -110,7 +106,6 @@ export function CombinedDiffSectionList({
|
||||
index={virtualItem.index}
|
||||
isBranchMode={isBranchMode}
|
||||
sideBySide={sideBySide}
|
||||
isDark={isDark}
|
||||
settings={settings}
|
||||
sectionHeight={sectionHeights[virtualItem.index]}
|
||||
worktreeId={file.worktreeId}
|
||||
@@ -136,7 +131,6 @@ export function CombinedDiffSectionList({
|
||||
}
|
||||
setSectionHeights={setSectionHeights}
|
||||
setSections={setSections}
|
||||
modifiedEditorsRef={modifiedEditorsRef}
|
||||
handleSectionSaveRef={handleSectionSaveRef}
|
||||
renderHeaderTrailingContent={(section) => {
|
||||
const fileNoteCount = commentCountByFilePath.get(section.path) ?? 0
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyDiffEditorLineNumberOptions,
|
||||
buildDiffEditorLineNumberOptions
|
||||
} from './diff-editor-line-number-options'
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
describe('buildDiffEditorLineNumberOptions', () => {
|
||||
it('hides original line numbers in inline mode', () => {
|
||||
expect(buildDiffEditorLineNumberOptions(false)).toEqual({
|
||||
original: 'off',
|
||||
modified: 'on'
|
||||
})
|
||||
})
|
||||
|
||||
it('shows both gutters in side-by-side mode', () => {
|
||||
expect(buildDiffEditorLineNumberOptions(true)).toEqual({
|
||||
original: 'on',
|
||||
modified: 'on'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function createMockCodeEditor(initialLineNumbers: editor.LineNumbersType = 'on'): {
|
||||
editor: editor.ICodeEditor
|
||||
emitDidChangeConfiguration: () => void
|
||||
getLineNumbers: () => editor.LineNumbersType
|
||||
} {
|
||||
let lineNumbers: editor.LineNumbersType = initialLineNumbers
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
const mockEditor = {
|
||||
getRawOptions: () => ({ lineNumbers }),
|
||||
updateOptions: ({ lineNumbers: nextLineNumbers }: { lineNumbers?: editor.LineNumbersType }) => {
|
||||
if (nextLineNumbers) {
|
||||
lineNumbers = nextLineNumbers
|
||||
}
|
||||
},
|
||||
onDidChangeConfiguration: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return {
|
||||
dispose: () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
} as unknown as editor.ICodeEditor
|
||||
|
||||
return {
|
||||
editor: mockEditor,
|
||||
emitDidChangeConfiguration: () => {
|
||||
listeners.forEach((listener) => listener())
|
||||
},
|
||||
getLineNumbers: () => lineNumbers
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyDiffEditorLineNumberOptions', () => {
|
||||
it('reapplies desired line number options after parent option updates and stops after dispose', () => {
|
||||
const original = createMockCodeEditor('on')
|
||||
const modified = createMockCodeEditor('on')
|
||||
const diffEditor = {
|
||||
getOriginalEditor: () => original.editor,
|
||||
getModifiedEditor: () => modified.editor
|
||||
} as unknown as editor.IStandaloneDiffEditor
|
||||
|
||||
const disposable = applyDiffEditorLineNumberOptions(diffEditor, false)
|
||||
|
||||
expect(original.getLineNumbers()).toBe('off')
|
||||
expect(modified.getLineNumbers()).toBe('on')
|
||||
|
||||
original.editor.updateOptions({ lineNumbers: 'on' })
|
||||
original.emitDidChangeConfiguration()
|
||||
expect(original.getLineNumbers()).toBe('off')
|
||||
|
||||
disposable.dispose()
|
||||
original.editor.updateOptions({ lineNumbers: 'on' })
|
||||
original.emitDidChangeConfiguration()
|
||||
expect(original.getLineNumbers()).toBe('on')
|
||||
})
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
type DiffEditorLineNumberOptions = {
|
||||
original: editor.LineNumbersType
|
||||
modified: editor.LineNumbersType
|
||||
}
|
||||
|
||||
type Disposable = {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function buildDiffEditorLineNumberOptions(sideBySide: boolean): DiffEditorLineNumberOptions {
|
||||
return {
|
||||
original: sideBySide ? 'on' : 'off',
|
||||
modified: 'on'
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDiffEditorLineNumberOptions(
|
||||
diffEditor: editor.IStandaloneDiffEditor,
|
||||
sideBySide: boolean
|
||||
): Disposable {
|
||||
const lineNumberOptions = buildDiffEditorLineNumberOptions(sideBySide)
|
||||
const originalEditor = diffEditor.getOriginalEditor()
|
||||
const modifiedEditor = diffEditor.getModifiedEditor()
|
||||
|
||||
const reapplyIfNeeded = (): void => {
|
||||
if (originalEditor.getRawOptions().lineNumbers !== lineNumberOptions.original) {
|
||||
originalEditor.updateOptions({ lineNumbers: lineNumberOptions.original })
|
||||
}
|
||||
if (modifiedEditor.getRawOptions().lineNumbers !== lineNumberOptions.modified) {
|
||||
modifiedEditor.updateOptions({ lineNumbers: lineNumberOptions.modified })
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Monaco 0.55 exposes only shared diff options for line numbers, so we
|
||||
// update the inner editors directly to collapse the duplicate gutter inline.
|
||||
reapplyIfNeeded()
|
||||
|
||||
// Why: @monaco-editor/react re-applies the parent options object on every
|
||||
// component re-render, which clobbers our per-pane lineNumbers override
|
||||
// (the parent options carry lineNumbers: 'on'). Subscribe to each inner
|
||||
// editor's onDidChangeConfiguration so we can re-assert the policy on
|
||||
// every option update without racing against Monaco's internal handling.
|
||||
const originalOptionsSub = originalEditor.onDidChangeConfiguration(reapplyIfNeeded)
|
||||
const modifiedOptionsSub = modifiedEditor.onDidChangeConfiguration(reapplyIfNeeded)
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
originalOptionsSub.dispose()
|
||||
modifiedOptionsSub.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
const DIFF_EDITOR_SCROLLBAR_SIZE = 20
|
||||
|
||||
// Why: diff panes are dense review surfaces; Monaco's default scrollbar is
|
||||
// too narrow to grab comfortably beside line decorations and change gutters.
|
||||
export const diffEditorScrollbarOptions = {
|
||||
verticalScrollbarSize: DIFF_EDITOR_SCROLLBAR_SIZE,
|
||||
horizontalScrollbarSize: DIFF_EDITOR_SCROLLBAR_SIZE,
|
||||
verticalSliderSize: DIFF_EDITOR_SCROLLBAR_SIZE,
|
||||
horizontalSliderSize: DIFF_EDITOR_SCROLLBAR_SIZE
|
||||
} satisfies editor.IEditorScrollbarOptions
|
||||
|
||||
export const combinedDiffSectionScrollbarOptions = {
|
||||
...diffEditorScrollbarOptions,
|
||||
vertical: 'hidden',
|
||||
handleMouseWheel: false
|
||||
} satisfies editor.IEditorScrollbarOptions
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildDiffEditorWhitespaceOptions } from './diff-editor-whitespace-options'
|
||||
|
||||
describe('buildDiffEditorWhitespaceOptions', () => {
|
||||
it('ignores trim whitespace by default', () => {
|
||||
expect(buildDiffEditorWhitespaceOptions(undefined)).toEqual({ ignoreTrimWhitespace: true })
|
||||
expect(buildDiffEditorWhitespaceOptions(false)).toEqual({ ignoreTrimWhitespace: true })
|
||||
})
|
||||
|
||||
it('includes whitespace in the diff when the preference is on', () => {
|
||||
expect(buildDiffEditorWhitespaceOptions(true)).toEqual({ ignoreTrimWhitespace: false })
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
export function buildDiffEditorWhitespaceOptions(
|
||||
diffShowWhitespace: boolean | undefined
|
||||
): Pick<editor.IStandaloneDiffEditorConstructionOptions, 'ignoreTrimWhitespace'> {
|
||||
return {
|
||||
// Why: Monaco defaults this to true, which hides indentation-only diffs.
|
||||
ignoreTrimWhitespace: diffShowWhitespace !== true
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildDiffEditorWordWrapOptions } from './diff-editor-word-wrap-options'
|
||||
|
||||
describe('buildDiffEditorWordWrapOptions', () => {
|
||||
it('keeps long diff lines unwrapped by default', () => {
|
||||
expect(buildDiffEditorWordWrapOptions(undefined)).toEqual({ wordWrap: 'off' })
|
||||
expect(buildDiffEditorWordWrapOptions(false)).toEqual({ wordWrap: 'off' })
|
||||
})
|
||||
|
||||
it('enables Monaco diff word wrapping when the diff preference is on', () => {
|
||||
expect(buildDiffEditorWordWrapOptions(true)).toEqual({ wordWrap: 'on' })
|
||||
})
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
export function buildDiffEditorWordWrapOptions(
|
||||
diffWordWrap: boolean | undefined
|
||||
): Pick<editor.IStandaloneDiffEditorConstructionOptions, 'wordWrap'> {
|
||||
return {
|
||||
wordWrap: diffWordWrap === true ? 'on' : 'off'
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { preserveDiffViewStateAcrossModelSwaps } from './diff-model-swap-view-state'
|
||||
|
||||
type ModelListener = () => void
|
||||
|
||||
function modelEditorFixture() {
|
||||
let willChangeListener: ModelListener = () => {}
|
||||
let didChangeListener: ModelListener = () => {}
|
||||
const disposeWillChange = vi.fn()
|
||||
const disposeDidChange = vi.fn()
|
||||
return {
|
||||
editor: {
|
||||
onWillChangeModel: (listener: ModelListener) => {
|
||||
willChangeListener = listener
|
||||
return { dispose: disposeWillChange }
|
||||
},
|
||||
onDidChangeModel: (listener: ModelListener) => {
|
||||
didChangeListener = listener
|
||||
return { dispose: disposeDidChange }
|
||||
}
|
||||
} as unknown as editor.ICodeEditor,
|
||||
fireWillChange: () => willChangeListener(),
|
||||
fireDidChange: () => didChangeListener(),
|
||||
disposeWillChange,
|
||||
disposeDidChange
|
||||
}
|
||||
}
|
||||
|
||||
describe('diff model swap view state', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('coalesces a two-sided model rotation into one view-state restore', () => {
|
||||
const original = modelEditorFixture()
|
||||
const modified = modelEditorFixture()
|
||||
const viewState = { original: {}, modified: {} } as editor.IDiffEditorViewState
|
||||
const diffEditor = {
|
||||
getOriginalEditor: () => original.editor,
|
||||
getModifiedEditor: () => modified.editor,
|
||||
getModel: () => ({ original: {}, modified: {} }),
|
||||
saveViewState: vi.fn(() => viewState),
|
||||
restoreViewState: vi.fn()
|
||||
} as unknown as editor.IStandaloneDiffEditor
|
||||
const scheduledFrames: FrameRequestCallback[] = []
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
scheduledFrames.push(callback)
|
||||
return scheduledFrames.length
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
|
||||
preserveDiffViewStateAcrossModelSwaps(diffEditor)
|
||||
original.fireWillChange()
|
||||
original.fireDidChange()
|
||||
modified.fireWillChange()
|
||||
modified.fireDidChange()
|
||||
|
||||
expect(diffEditor.saveViewState).toHaveBeenCalledOnce()
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(1)
|
||||
scheduledFrames[1](0)
|
||||
expect(diffEditor.restoreViewState).toHaveBeenCalledOnce()
|
||||
expect(diffEditor.restoreViewState).toHaveBeenCalledWith(viewState)
|
||||
})
|
||||
|
||||
it('cancels pending work and listeners when the editor is disposed', () => {
|
||||
const original = modelEditorFixture()
|
||||
const modified = modelEditorFixture()
|
||||
const diffEditor = {
|
||||
getOriginalEditor: () => original.editor,
|
||||
getModifiedEditor: () => modified.editor,
|
||||
saveViewState: () => ({ original: {}, modified: {} }),
|
||||
restoreViewState: vi.fn()
|
||||
} as unknown as editor.IStandaloneDiffEditor
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(7)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
const subscription = preserveDiffViewStateAcrossModelSwaps(diffEditor)
|
||||
|
||||
modified.fireWillChange()
|
||||
modified.fireDidChange()
|
||||
subscription.dispose()
|
||||
|
||||
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
|
||||
expect(original.disposeWillChange).toHaveBeenCalledOnce()
|
||||
expect(original.disposeDidChange).toHaveBeenCalledOnce()
|
||||
expect(modified.disposeWillChange).toHaveBeenCalledOnce()
|
||||
expect(modified.disposeDidChange).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { IDisposable, editor } from 'monaco-editor'
|
||||
|
||||
export function preserveDiffViewStateAcrossModelSwaps(
|
||||
diffEditor: editor.IStandaloneDiffEditor
|
||||
): IDisposable {
|
||||
let pendingViewState: editor.IDiffEditorViewState | null = null
|
||||
let restoreFrame: number | null = null
|
||||
|
||||
const captureViewState = (): void => {
|
||||
// Why: Monaco resets cursor and scroll state when a retained editor swaps
|
||||
// models, so capture once before either diff side starts rotating.
|
||||
pendingViewState ??= diffEditor.saveViewState()
|
||||
}
|
||||
const scheduleRestore = (): void => {
|
||||
if (!pendingViewState) {
|
||||
return
|
||||
}
|
||||
if (restoreFrame !== null) {
|
||||
cancelAnimationFrame(restoreFrame)
|
||||
}
|
||||
restoreFrame = requestAnimationFrame(() => {
|
||||
restoreFrame = null
|
||||
const viewState = pendingViewState
|
||||
pendingViewState = null
|
||||
if (viewState && diffEditor.getModel()) {
|
||||
diffEditor.restoreViewState(viewState)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const originalEditor = diffEditor.getOriginalEditor()
|
||||
const modifiedEditor = diffEditor.getModifiedEditor()
|
||||
const subscriptions = [
|
||||
originalEditor.onWillChangeModel(captureViewState),
|
||||
originalEditor.onDidChangeModel(scheduleRestore),
|
||||
modifiedEditor.onWillChangeModel(captureViewState),
|
||||
modifiedEditor.onDidChangeModel(scheduleRestore)
|
||||
]
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.dispose()
|
||||
}
|
||||
if (restoreFrame !== null) {
|
||||
cancelAnimationFrame(restoreFrame)
|
||||
}
|
||||
restoreFrame = null
|
||||
pendingViewState = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,52 +2,29 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import {
|
||||
DiffNavigationProvider,
|
||||
useDiffEditorRegistration,
|
||||
useDiffNavigatorRegistration,
|
||||
useDiffNavigation,
|
||||
type DiffEditorRegistrationContextValue,
|
||||
type DiffNavigator,
|
||||
type DiffNavigatorRegistrationContextValue,
|
||||
type DiffNavigationContextValue
|
||||
} from './diff-navigation-context'
|
||||
|
||||
type FakeDiffEditor = editor.IStandaloneDiffEditor & {
|
||||
setLineChanges: (count: number) => void
|
||||
fireUpdate: () => void
|
||||
goToDiff: ReturnType<typeof vi.fn>
|
||||
disposeUpdate: ReturnType<typeof vi.fn>
|
||||
containerNode: HTMLElement
|
||||
type FakeNavigator = DiffNavigator & {
|
||||
scrollToChange: DiffNavigator['scrollToChange'] & { mock: unknown }
|
||||
}
|
||||
|
||||
function createFakeEditor(initialCount: number): FakeDiffEditor {
|
||||
let count = initialCount
|
||||
let updateCallback: (() => void) | null = null
|
||||
const disposeUpdate = vi.fn(() => {
|
||||
updateCallback = null
|
||||
})
|
||||
const containerNode = document.createElement('div')
|
||||
const editor = {
|
||||
getLineChanges: () => (count > 0 ? Array.from({ length: count }, () => ({})) : []),
|
||||
goToDiff: vi.fn(),
|
||||
getContainerDomNode: () => containerNode,
|
||||
onDidUpdateDiff: (cb: () => void) => {
|
||||
updateCallback = cb
|
||||
return {
|
||||
dispose: disposeUpdate
|
||||
}
|
||||
},
|
||||
setLineChanges: (next: number) => {
|
||||
count = next
|
||||
},
|
||||
fireUpdate: () => updateCallback?.(),
|
||||
disposeUpdate,
|
||||
containerNode
|
||||
} as unknown as FakeDiffEditor
|
||||
return editor
|
||||
function createFakeNavigator(changeLines: number[]): FakeNavigator {
|
||||
return {
|
||||
changeLines,
|
||||
container: document.createElement('div'),
|
||||
scrollToChange: vi.fn<DiffNavigator['scrollToChange']>()
|
||||
} as FakeNavigator
|
||||
}
|
||||
|
||||
let captured: DiffNavigationContextValue | null = null
|
||||
let registration: DiffEditorRegistrationContextValue | null = null
|
||||
let registration: DiffNavigatorRegistrationContextValue | null = null
|
||||
let registrationRenderCount = 0
|
||||
|
||||
function Probe(): null {
|
||||
@@ -56,7 +33,7 @@ function Probe(): null {
|
||||
}
|
||||
|
||||
function RegistrationProbe(): null {
|
||||
registration = useDiffEditorRegistration()
|
||||
registration = useDiffNavigatorRegistration()
|
||||
registrationRenderCount += 1
|
||||
return null
|
||||
}
|
||||
@@ -91,83 +68,107 @@ describe('DiffNavigationProvider', () => {
|
||||
registrationRenderCount = 0
|
||||
})
|
||||
|
||||
it('exposes the change count and routes nav actions to the registered editor', () => {
|
||||
it('exposes the change count and routes nav actions to the registered navigator', () => {
|
||||
mount()
|
||||
const editor = createFakeEditor(3)
|
||||
act(() => registration?.registerDiffEditor(editor))
|
||||
const navigator = createFakeNavigator([4, 20, 61])
|
||||
act(() => registration?.registerDiffNavigator(navigator))
|
||||
|
||||
expect(captured?.changeCount).toBe(3)
|
||||
|
||||
act(() => captured?.goToNextDiff())
|
||||
expect(editor.goToDiff).toHaveBeenCalledWith('next')
|
||||
expect(navigator.scrollToChange).toHaveBeenCalledWith({
|
||||
lineNumber: 4,
|
||||
hunkIndex: 0,
|
||||
hunkCount: 3
|
||||
})
|
||||
|
||||
act(() => captured?.goToPreviousDiff())
|
||||
expect(editor.goToDiff).toHaveBeenCalledWith('previous')
|
||||
act(() => captured?.goToNextDiff())
|
||||
expect(navigator.scrollToChange).toHaveBeenLastCalledWith({
|
||||
lineNumber: 20,
|
||||
hunkIndex: 1,
|
||||
hunkCount: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('re-renders when onDidUpdateDiff flips the count 0 -> N (count is state)', () => {
|
||||
it('wraps the cursor at both ends', () => {
|
||||
mount()
|
||||
const editor = createFakeEditor(0)
|
||||
act(() => registration?.registerDiffEditor(editor))
|
||||
expect(captured?.changeCount).toBe(0)
|
||||
const navigator = createFakeNavigator([4, 20])
|
||||
act(() => registration?.registerDiffNavigator(navigator))
|
||||
|
||||
act(() => {
|
||||
editor.setLineChanges(2)
|
||||
editor.fireUpdate()
|
||||
// Previous from the initial position lands on the last change.
|
||||
act(() => captured?.goToPreviousDiff())
|
||||
expect(navigator.scrollToChange).toHaveBeenLastCalledWith({
|
||||
lineNumber: 20,
|
||||
hunkIndex: 1,
|
||||
hunkCount: 2
|
||||
})
|
||||
|
||||
act(() => captured?.goToNextDiff())
|
||||
expect(navigator.scrollToChange).toHaveBeenLastCalledWith({
|
||||
lineNumber: 4,
|
||||
hunkIndex: 0,
|
||||
hunkCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('does nothing when the registered navigator has no changes', () => {
|
||||
mount()
|
||||
const navigator = createFakeNavigator([])
|
||||
act(() => registration?.registerDiffNavigator(navigator))
|
||||
|
||||
expect(captured?.changeCount).toBe(0)
|
||||
act(() => captured?.goToNextDiff())
|
||||
expect(navigator.scrollToChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a stale unregister for a navigator that is no longer current (identity guard)', () => {
|
||||
mount()
|
||||
const oldNavigator = createFakeNavigator([1])
|
||||
const newNavigator = createFakeNavigator([2, 3, 4, 5])
|
||||
|
||||
// Fast-swap: new navigator registers before the old one's teardown fires.
|
||||
act(() => registration?.registerDiffNavigator(oldNavigator))
|
||||
act(() => registration?.registerDiffNavigator(newNavigator))
|
||||
expect(captured?.changeCount).toBe(4)
|
||||
|
||||
act(() => registration?.unregisterDiffNavigator(oldNavigator))
|
||||
|
||||
// New navigator's count is intact and nav still routes to it.
|
||||
expect(captured?.changeCount).toBe(4)
|
||||
act(() => captured?.goToNextDiff())
|
||||
expect(newNavigator.scrollToChange).toHaveBeenCalledOnce()
|
||||
expect(oldNavigator.scrollToChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the registration context identity stable across count changes', () => {
|
||||
mount()
|
||||
act(() => registration?.registerDiffNavigator(createFakeNavigator([1, 2])))
|
||||
expect(captured?.changeCount).toBe(2)
|
||||
expect(registrationRenderCount).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores a stale unregister for an editor that is no longer current (identity guard)', () => {
|
||||
it('installs a capture-phase key listener on register and removes it on unregister', () => {
|
||||
mount()
|
||||
const oldEditor = createFakeEditor(1)
|
||||
const newEditor = createFakeEditor(4)
|
||||
const navigator = createFakeNavigator([2])
|
||||
const addSpy = vi.spyOn(navigator.container, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(navigator.container, 'removeEventListener')
|
||||
|
||||
// Fast-swap: new editor registers before the old one's dispose fires.
|
||||
act(() => registration?.registerDiffEditor(oldEditor))
|
||||
act(() => registration?.registerDiffEditor(newEditor))
|
||||
expect(captured?.changeCount).toBe(4)
|
||||
expect(oldEditor.disposeUpdate).toHaveBeenCalledOnce()
|
||||
act(() => registration?.registerDiffNavigator(navigator))
|
||||
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
|
||||
|
||||
// A stale update from the old editor must not flip the count back: registering
|
||||
// the new editor disposed the old subscription, so its callback no longer fires.
|
||||
act(() => {
|
||||
oldEditor.setLineChanges(9)
|
||||
oldEditor.fireUpdate()
|
||||
})
|
||||
expect(captured?.changeCount).toBe(4)
|
||||
|
||||
act(() => registration?.unregisterDiffEditor(oldEditor))
|
||||
|
||||
// New editor's count is intact and nav still routes to it.
|
||||
expect(captured?.changeCount).toBe(4)
|
||||
act(() => captured?.goToNextDiff())
|
||||
expect(newEditor.goToDiff).toHaveBeenCalledWith('next')
|
||||
expect(oldEditor.goToDiff).not.toHaveBeenCalled()
|
||||
act(() => registration?.unregisterDiffNavigator(navigator))
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
|
||||
})
|
||||
|
||||
it('disposes the active diff update subscription when the provider unmounts', () => {
|
||||
it('removes the key listener when the provider unmounts', () => {
|
||||
mount()
|
||||
const editor = createFakeEditor(1)
|
||||
act(() => registration?.registerDiffEditor(editor))
|
||||
const navigator = createFakeNavigator([1])
|
||||
const removeSpy = vi.spyOn(navigator.container, 'removeEventListener')
|
||||
act(() => registration?.registerDiffNavigator(navigator))
|
||||
|
||||
act(() => root?.unmount())
|
||||
|
||||
expect(editor.disposeUpdate).toHaveBeenCalledOnce()
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
|
||||
root = null
|
||||
})
|
||||
|
||||
it('installs a capture-phase key listener on register and removes it on unregister', () => {
|
||||
mount()
|
||||
const editor = createFakeEditor(2)
|
||||
const addSpy = vi.spyOn(editor.containerNode, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(editor.containerNode, 'removeEventListener')
|
||||
|
||||
act(() => registration?.registerDiffEditor(editor))
|
||||
expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
|
||||
|
||||
act(() => registration?.unregisterDiffEditor(editor))
|
||||
expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { installMonacoDiffChangeNavigationShortcut } from './editor-shortcuts'
|
||||
import { installDiffChangeNavigationShortcut } from './editor-shortcuts'
|
||||
|
||||
export type DiffEditorRegistrationContextValue = {
|
||||
registerDiffEditor: (editor: editor.IStandaloneDiffEditor) => void
|
||||
unregisterDiffEditor: (editor: editor.IStandaloneDiffEditor) => void
|
||||
/** A mounted diff that can report its changes and scroll to one. */
|
||||
export type DiffNavigator = {
|
||||
/** Modified-side start line of every hunk, in document order. */
|
||||
changeLines: readonly number[]
|
||||
scrollToChange: (args: { lineNumber: number; hunkIndex: number; hunkCount: number }) => void
|
||||
/** Element the F7 / Shift+F7 listener attaches to. */
|
||||
container: HTMLElement
|
||||
}
|
||||
|
||||
export type DiffNavigatorRegistrationContextValue = {
|
||||
registerDiffNavigator: (navigator: DiffNavigator) => void
|
||||
unregisterDiffNavigator: (navigator: DiffNavigator) => void
|
||||
}
|
||||
|
||||
export type DiffNavigationContextValue = {
|
||||
@@ -16,10 +24,10 @@ export type DiffNavigationContextValue = {
|
||||
const noop = (): void => {}
|
||||
|
||||
// Why: registration stays separate from changeCount so diff recomputation only
|
||||
// rerenders the header controls, not the heavy Monaco DiffViewer consumer.
|
||||
const DiffEditorRegistrationContext = createContext<DiffEditorRegistrationContextValue>({
|
||||
registerDiffEditor: noop,
|
||||
unregisterDiffEditor: noop
|
||||
// rerenders the header controls, not the heavy diff consumer.
|
||||
const DiffNavigatorRegistrationContext = createContext<DiffNavigatorRegistrationContextValue>({
|
||||
registerDiffNavigator: noop,
|
||||
unregisterDiffNavigator: noop
|
||||
})
|
||||
|
||||
const DiffNavigationContext = createContext<DiffNavigationContextValue>({
|
||||
@@ -28,97 +36,104 @@ const DiffNavigationContext = createContext<DiffNavigationContextValue>({
|
||||
changeCount: 0
|
||||
})
|
||||
|
||||
function countChanges(diffEditor: editor.IStandaloneDiffEditor): number {
|
||||
return diffEditor.getLineChanges()?.length ?? 0
|
||||
}
|
||||
|
||||
export function DiffNavigationProvider({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const editorRef = useRef<editor.IStandaloneDiffEditor | null>(null)
|
||||
const updateSubRef = useRef<{ dispose: () => void } | null>(null)
|
||||
// Why: F7/Shift+F7 change navigation shares the registered editor with the
|
||||
// header buttons, so the keyboard listener lives here rather than in DiffViewer.
|
||||
const navigatorRef = useRef<DiffNavigator | null>(null)
|
||||
const shortcutCleanupRef = useRef<(() => void) | null>(null)
|
||||
// Why: the cursor is provider-owned because the renderer no longer tracks a
|
||||
// "current change" of its own the way Monaco's goToDiff did.
|
||||
const cursorRef = useRef(-1)
|
||||
// Why: changeCount must be state, not a ref — the header is a sibling consumer
|
||||
// and only re-renders (enabling the buttons) when the value object identity
|
||||
// changes on the 0 -> N flip once the diff computation lands.
|
||||
// and only re-renders (enabling the buttons) when the value identity changes.
|
||||
const [changeCount, setChangeCount] = useState(0)
|
||||
|
||||
const registerDiffEditor = useCallback((diffEditor: editor.IStandaloneDiffEditor) => {
|
||||
editorRef.current = diffEditor
|
||||
// Hold at most one update subscription; replace any prior editor's.
|
||||
updateSubRef.current?.dispose()
|
||||
updateSubRef.current = diffEditor.onDidUpdateDiff(() => {
|
||||
// Why: ignore updates from an editor that is no longer current so a stale
|
||||
// subscription in the fast-swap case can't write a wrong count.
|
||||
if (editorRef.current === diffEditor) {
|
||||
setChangeCount(countChanges(diffEditor))
|
||||
}
|
||||
const goToChange = useCallback((direction: 'next' | 'previous') => {
|
||||
const navigator = navigatorRef.current
|
||||
if (!navigator) {
|
||||
return
|
||||
}
|
||||
const total = navigator.changeLines.length
|
||||
if (total === 0) {
|
||||
return
|
||||
}
|
||||
// Why: from the initial position, `next` lands on the first change and
|
||||
// `previous` wraps to the last — plain modulo would send both to index 0.
|
||||
const step = direction === 'next' ? 1 : -1
|
||||
const nextIndex =
|
||||
cursorRef.current === -1
|
||||
? direction === 'next'
|
||||
? 0
|
||||
: total - 1
|
||||
: (((cursorRef.current + step) % total) + total) % total
|
||||
cursorRef.current = nextIndex
|
||||
navigator.scrollToChange({
|
||||
lineNumber: navigator.changeLines[nextIndex],
|
||||
hunkIndex: nextIndex,
|
||||
hunkCount: total
|
||||
})
|
||||
// Hold at most one keyboard listener; replace any prior editor's.
|
||||
shortcutCleanupRef.current?.()
|
||||
shortcutCleanupRef.current = installMonacoDiffChangeNavigationShortcut(diffEditor)
|
||||
setChangeCount(countChanges(diffEditor))
|
||||
}, [])
|
||||
|
||||
const unregisterDiffEditor = useCallback((diffEditor: editor.IStandaloneDiffEditor) => {
|
||||
// Why: identity guard for the fast-swap race — a stale dispose carrying the
|
||||
// old editor must not wipe a freshly-registered new one.
|
||||
if (editorRef.current !== diffEditor) {
|
||||
const registerDiffNavigator = useCallback(
|
||||
(navigator: DiffNavigator) => {
|
||||
navigatorRef.current = navigator
|
||||
cursorRef.current = -1
|
||||
// Hold at most one keyboard listener; replace any prior navigator's.
|
||||
shortcutCleanupRef.current?.()
|
||||
shortcutCleanupRef.current = installDiffChangeNavigationShortcut(
|
||||
navigator.container,
|
||||
goToChange
|
||||
)
|
||||
setChangeCount(navigator.changeLines.length)
|
||||
},
|
||||
[goToChange]
|
||||
)
|
||||
|
||||
const unregisterDiffNavigator = useCallback((navigator: DiffNavigator) => {
|
||||
// Why: identity guard for the fast-swap race — a stale teardown carrying the
|
||||
// old navigator must not wipe a freshly-registered new one.
|
||||
if (navigatorRef.current !== navigator) {
|
||||
return
|
||||
}
|
||||
updateSubRef.current?.dispose()
|
||||
updateSubRef.current = null
|
||||
shortcutCleanupRef.current?.()
|
||||
shortcutCleanupRef.current = null
|
||||
editorRef.current = null
|
||||
navigatorRef.current = null
|
||||
cursorRef.current = -1
|
||||
setChangeCount(0)
|
||||
}, [])
|
||||
|
||||
const goToPreviousDiff = useCallback(() => {
|
||||
editorRef.current?.goToDiff('previous')
|
||||
}, [])
|
||||
|
||||
const goToNextDiff = useCallback(() => {
|
||||
editorRef.current?.goToDiff('next')
|
||||
}, [])
|
||||
const goToPreviousDiff = useCallback(() => goToChange('previous'), [goToChange])
|
||||
const goToNextDiff = useCallback(() => goToChange('next'), [goToChange])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateSubRef.current?.dispose()
|
||||
updateSubRef.current = null
|
||||
shortcutCleanupRef.current?.()
|
||||
shortcutCleanupRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const registrationValue = useMemo(
|
||||
() => ({ registerDiffEditor, unregisterDiffEditor }),
|
||||
[registerDiffEditor, unregisterDiffEditor]
|
||||
() => ({ registerDiffNavigator, unregisterDiffNavigator }),
|
||||
[registerDiffNavigator, unregisterDiffNavigator]
|
||||
)
|
||||
const navigationValue = useMemo(
|
||||
() => ({
|
||||
goToPreviousDiff,
|
||||
goToNextDiff,
|
||||
changeCount
|
||||
}),
|
||||
() => ({ goToPreviousDiff, goToNextDiff, changeCount }),
|
||||
[goToPreviousDiff, goToNextDiff, changeCount]
|
||||
)
|
||||
|
||||
return (
|
||||
<DiffEditorRegistrationContext.Provider value={registrationValue}>
|
||||
<DiffNavigatorRegistrationContext.Provider value={registrationValue}>
|
||||
<DiffNavigationContext.Provider value={navigationValue}>
|
||||
{children}
|
||||
</DiffNavigationContext.Provider>
|
||||
</DiffEditorRegistrationContext.Provider>
|
||||
</DiffNavigatorRegistrationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDiffEditorRegistration(): DiffEditorRegistrationContextValue {
|
||||
return useContext(DiffEditorRegistrationContext)
|
||||
export function useDiffNavigatorRegistration(): DiffNavigatorRegistrationContextValue {
|
||||
return useContext(DiffNavigatorRegistrationContext)
|
||||
}
|
||||
|
||||
export function useDiffNavigation(): DiffNavigationContextValue {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Dispatch, MutableRefObject, ReactNode, SetStateAction } from 'react'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import type { DecoratedDiffComment } from '../diff-comments/decorated-diff-comment'
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
|
||||
@@ -8,10 +7,11 @@ export type DiffSectionItemProps = {
|
||||
index: number
|
||||
isBranchMode: boolean
|
||||
sideBySide: boolean
|
||||
isDark: boolean
|
||||
settings: {
|
||||
theme?: 'system' | 'dark' | 'light'
|
||||
terminalFontSize?: number
|
||||
terminalFontFamily?: string
|
||||
editorFontFamily?: string
|
||||
diffWordWrap?: boolean
|
||||
diffShowWhitespace?: boolean
|
||||
} | null
|
||||
@@ -35,6 +35,5 @@ export type DiffSectionItemProps = {
|
||||
getCommentableLineNumbers?: (section: DiffSection) => readonly number[] | undefined
|
||||
setSectionHeights: Dispatch<SetStateAction<Record<number, number>>>
|
||||
setSections: Dispatch<SetStateAction<DiffSection[]>>
|
||||
modifiedEditorsRef: MutableRefObject<Map<number, monacoEditor.IStandaloneCodeEditor>>
|
||||
handleSectionSaveRef: MutableRefObject<(index: number) => Promise<void>>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import type { DiffSection } from './diff-section-types'
|
||||
import {
|
||||
getLargeDiffRenderLimitFromCounts,
|
||||
@@ -7,19 +6,13 @@ import {
|
||||
|
||||
export function getLiveDiffSectionRenderLimit({
|
||||
section,
|
||||
modifiedEditor,
|
||||
modifiedContent
|
||||
}: {
|
||||
section: DiffSection
|
||||
modifiedEditor: monacoEditor.ICodeEditor
|
||||
modifiedContent: string
|
||||
}): LargeDiffRenderLimit {
|
||||
const modifiedLineCount =
|
||||
modifiedContent.length === 0
|
||||
? 0
|
||||
: (modifiedEditor.getModel()?.getLineCount() ??
|
||||
section.largeDiffRenderLimit?.lineCounts?.modified ??
|
||||
0)
|
||||
// Why: the renderer no longer owns a text model, so count lines from the draft itself.
|
||||
const modifiedLineCount = modifiedContent.length === 0 ? 0 : modifiedContent.split('\n').length
|
||||
|
||||
return getLargeDiffRenderLimitFromCounts({
|
||||
originalLineCount: section.largeDiffRenderLimit?.lineCounts?.original ?? 0,
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('@/store', () => ({
|
||||
import {
|
||||
installEditorAddReviewNoteShortcut,
|
||||
installEditorFindShortcut,
|
||||
installMonacoDiffChangeNavigationShortcut,
|
||||
installDiffChangeNavigationShortcut,
|
||||
installMonacoEditorFindShortcut,
|
||||
installOpenDraftAddReviewNoteGuard
|
||||
} from './editor-shortcuts'
|
||||
@@ -315,7 +315,7 @@ describe('installOpenDraftAddReviewNoteGuard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('installMonacoDiffChangeNavigationShortcut', () => {
|
||||
describe('installDiffChangeNavigationShortcut', () => {
|
||||
function createDiffNavigationFixture(): {
|
||||
container: HTMLDivElement
|
||||
dispose: () => void
|
||||
@@ -333,10 +333,7 @@ describe('installMonacoDiffChangeNavigationShortcut', () => {
|
||||
|
||||
return {
|
||||
container,
|
||||
dispose: installMonacoDiffChangeNavigationShortcut({
|
||||
getContainerDomNode: () => container,
|
||||
goToDiff
|
||||
}),
|
||||
dispose: installDiffChangeNavigationShortcut(container, goToDiff),
|
||||
input,
|
||||
goToDiff,
|
||||
onDownstreamKeyDown
|
||||
|
||||
@@ -46,13 +46,9 @@ export function installEditorFindShortcut(target: HTMLElement, onFind: () => voi
|
||||
return () => target.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
|
||||
type MonacoDiffNavigationEditor = {
|
||||
getContainerDomNode: () => HTMLElement
|
||||
goToDiff: (target: 'next' | 'previous') => void
|
||||
}
|
||||
|
||||
export function installMonacoDiffChangeNavigationShortcut(
|
||||
editor: MonacoDiffNavigationEditor
|
||||
export function installDiffChangeNavigationShortcut(
|
||||
target: HTMLElement,
|
||||
goToDiff: (direction: 'next' | 'previous') => void
|
||||
): () => void {
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
let direction: 'next' | 'previous' | null = null
|
||||
@@ -64,17 +60,15 @@ export function installMonacoDiffChangeNavigationShortcut(
|
||||
if (!direction) {
|
||||
return
|
||||
}
|
||||
// Why: capture-phase preventDefault/stopPropagation beats Monaco's built-in
|
||||
// F7 accessible-review pane, like the find shortcut does for Cmd+F.
|
||||
// Why: capture-phase so the renderer's own F7 handling never sees it first.
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// Consume matched repeats but navigate once per press (matches find shortcut).
|
||||
if (!event.repeat) {
|
||||
editor.goToDiff(direction)
|
||||
goToDiff(direction)
|
||||
}
|
||||
}
|
||||
|
||||
const target = editor.getContainerDomNode()
|
||||
target.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => target.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PierreDiffWorkerPoolProvider } from './pierre-diff-worker-pool'
|
||||
import { PierreDiffEditProvider } from './pierre-diff-editor-provider'
|
||||
|
||||
/**
|
||||
* Wraps a diff surface with the Shiki worker pool and the editor factory.
|
||||
* Pierre reference-counts the pool, so every diff view can mount this and they
|
||||
* still share one set of workers.
|
||||
*/
|
||||
export function PierreDiffProviders({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<PierreDiffWorkerPoolProvider>
|
||||
<PierreDiffEditProvider>{children}</PierreDiffEditProvider>
|
||||
</PierreDiffWorkerPoolProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { FileDiff } from '@pierre/diffs/react'
|
||||
import type { FileDiffMetadata, PostRenderPhase, SelectedLineRange } from '@pierre/diffs'
|
||||
import type { FileContents } from '@pierre/diffs'
|
||||
import type { EditorOptions } from '@pierre/diffs/edit'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { DecoratedDiffComment } from '../../diff-comments/decorated-diff-comment'
|
||||
import { buildPierreDiffOptions, buildPierreDiffStyle } from './pierre-diff-options'
|
||||
import type { PierreDiffSettings } from './pierre-diff-options'
|
||||
import {
|
||||
buildPierreDiffCommentAnnotations,
|
||||
renderPierreDiffCommentAnnotation,
|
||||
type PierreDiffAnnotationData,
|
||||
type PierreDiffCommentAnnotation
|
||||
} from './pierre-diff-comment-annotations'
|
||||
import { usePierreDiffFind } from './use-pierre-diff-find'
|
||||
|
||||
export type PierreDiffSurfaceProps = {
|
||||
fileDiff: FileDiffMetadata
|
||||
sideBySide: boolean
|
||||
settings?: PierreDiffSettings | null
|
||||
isEditable: boolean
|
||||
worktreeId: string
|
||||
filePath: string
|
||||
comments: readonly DecoratedDiffComment[]
|
||||
formatCommentPrompt?: (comment: DecoratedDiffComment) => string
|
||||
onDeleteComment: (commentId: string) => void
|
||||
onUpdateComment?: (commentId: string, body: string) => Promise<boolean>
|
||||
/** Live document stream while an edit session is active. */
|
||||
onEditChange?: (file: FileContents) => void
|
||||
/** Fires on Pierre's DOM lifecycle; used for height measurement. */
|
||||
onPostRender?: (node: HTMLElement, phase: PostRenderPhase) => void
|
||||
/** Gutter affordance for starting a note; omit to hide it. */
|
||||
onAddComment?: (range: { lineNumber: number; startLine?: number }) => void
|
||||
/** Open note draft, rendered inline on its anchor line. */
|
||||
pendingComment?: { lineNumber: number; startLine?: number } | null
|
||||
addCommentPlaceholder?: string
|
||||
addCommentLabel?: string
|
||||
onCancelComment?: () => void
|
||||
onSubmitComment?: (body: string) => Promise<void>
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The single diff renderer behind every Orca diff surface. Replaces the Monaco
|
||||
* `DiffEditor` that used to mount once per visible file.
|
||||
*/
|
||||
export function PierreDiffSurface({
|
||||
fileDiff,
|
||||
sideBySide,
|
||||
settings,
|
||||
isEditable,
|
||||
worktreeId,
|
||||
filePath,
|
||||
comments,
|
||||
formatCommentPrompt,
|
||||
onDeleteComment,
|
||||
onUpdateComment,
|
||||
onEditChange,
|
||||
onPostRender,
|
||||
onAddComment,
|
||||
pendingComment,
|
||||
addCommentPlaceholder,
|
||||
addCommentLabel,
|
||||
onCancelComment,
|
||||
onSubmitComment,
|
||||
className
|
||||
}: PierreDiffSurfaceProps): React.JSX.Element {
|
||||
const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel)
|
||||
const clearDeliveredDiffComments = useAppStore((s) => s.clearDeliveredDiffComments)
|
||||
const activeGroupId = useAppStore((s) =>
|
||||
worktreeId ? (s.activeGroupIdByWorktree[worktreeId] ?? worktreeId) : worktreeId
|
||||
)
|
||||
const { editEnabled, handleContainerKeyDown, handleEditorAttach } = usePierreDiffFind({
|
||||
isEditable
|
||||
})
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
...buildPierreDiffOptions<PierreDiffAnnotationData>({ settings, sideBySide }),
|
||||
enableGutterUtility: Boolean(onAddComment),
|
||||
onGutterUtilityClick: onAddComment
|
||||
? (range: SelectedLineRange) =>
|
||||
onAddComment({
|
||||
lineNumber: Math.max(range.start, range.end),
|
||||
startLine: range.start === range.end ? undefined : Math.min(range.start, range.end)
|
||||
})
|
||||
: undefined,
|
||||
onPostRender: onPostRender
|
||||
? (node: HTMLElement, _instance: unknown, phase: PostRenderPhase) =>
|
||||
onPostRender(node, phase)
|
||||
: undefined
|
||||
}),
|
||||
[settings, sideBySide, onPostRender, onAddComment]
|
||||
)
|
||||
const style = useMemo(
|
||||
() => buildPierreDiffStyle(settings, editorFontZoomLevel),
|
||||
[settings, editorFontZoomLevel]
|
||||
)
|
||||
const lineAnnotations = useMemo(
|
||||
() => buildPierreDiffCommentAnnotations(comments, pendingComment),
|
||||
[comments, pendingComment]
|
||||
)
|
||||
const editorOptions = useMemo<EditorOptions<PierreDiffAnnotationData>>(
|
||||
() => ({
|
||||
onAttach: handleEditorAttach,
|
||||
onChange: (file) => onEditChange?.(file)
|
||||
}),
|
||||
[handleEditorAttach, onEditChange]
|
||||
)
|
||||
const renderAnnotation = useCallback(
|
||||
(annotation: PierreDiffCommentAnnotation) =>
|
||||
renderPierreDiffCommentAnnotation(annotation, {
|
||||
worktreeId,
|
||||
filePath,
|
||||
activeGroupId,
|
||||
formatCommentPrompt,
|
||||
onDeleteComment,
|
||||
onUpdateComment,
|
||||
clearDeliveredDiffComments,
|
||||
draftPlaceholder: addCommentPlaceholder,
|
||||
draftSubmitLabel: addCommentLabel,
|
||||
onCancelDraft: onCancelComment,
|
||||
onSubmitDraft: onSubmitComment
|
||||
}),
|
||||
[
|
||||
worktreeId,
|
||||
filePath,
|
||||
activeGroupId,
|
||||
formatCommentPrompt,
|
||||
onDeleteComment,
|
||||
onUpdateComment,
|
||||
clearDeliveredDiffComments,
|
||||
addCommentPlaceholder,
|
||||
addCommentLabel,
|
||||
onCancelComment,
|
||||
onSubmitComment
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
// Why: ⌘F must be caught before Pierre mounts an editor, so the listener lives on the host.
|
||||
<div className={className} onKeyDownCapture={handleContainerKeyDown}>
|
||||
<FileDiff<PierreDiffAnnotationData>
|
||||
fileDiff={fileDiff}
|
||||
options={options}
|
||||
style={style}
|
||||
edit={editEnabled}
|
||||
editorOptions={editorOptions}
|
||||
lineAnnotations={lineAnnotations}
|
||||
renderAnnotation={renderAnnotation}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Pierre renders into a shadow root, so `document.activeElement` stops at the
|
||||
* host. Walk shadow roots to reach the element that actually holds focus.
|
||||
*/
|
||||
export function getDeepActiveElement(): Element | null {
|
||||
let active = document.activeElement
|
||||
while (active?.shadowRoot?.activeElement) {
|
||||
active = active.shadowRoot.activeElement
|
||||
}
|
||||
return active
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { DiffLineAnnotation } from '@pierre/diffs'
|
||||
import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat'
|
||||
import { DiffCommentCard } from '../../diff-comments/DiffCommentCard'
|
||||
import { DiffCommentPopover } from '../../diff-comments/DiffCommentPopover'
|
||||
import { getSingleCommentSendScopes } from '../../diff-comments/diff-comment-send-scopes'
|
||||
import type { DecoratedDiffComment } from '../../diff-comments/decorated-diff-comment'
|
||||
import type { DiffCommentDeliverySnapshot } from '@/store/slices/diffComments'
|
||||
import { NotesSendMenu } from '../NotesSendMenu'
|
||||
|
||||
/** A saved note, or the draft the gutter affordance just opened. */
|
||||
export type PierreDiffAnnotationData =
|
||||
| { kind: 'comment'; comment: DecoratedDiffComment }
|
||||
| { kind: 'draft'; lineNumber: number; startLine?: number }
|
||||
|
||||
export type PierreDiffCommentAnnotation = DiffLineAnnotation<PierreDiffAnnotationData>
|
||||
|
||||
/**
|
||||
* Notes live on the modified side, which Pierre calls `additions`. Pierre
|
||||
* measures annotation rows itself, so unlike the Monaco view zones these
|
||||
* replace, no height bookkeeping is needed.
|
||||
*/
|
||||
export function buildPierreDiffCommentAnnotations(
|
||||
comments: readonly DecoratedDiffComment[],
|
||||
draft?: { lineNumber: number; startLine?: number } | null
|
||||
): PierreDiffCommentAnnotation[] {
|
||||
const annotations: PierreDiffCommentAnnotation[] = comments.map((comment) => ({
|
||||
side: 'additions',
|
||||
lineNumber: comment.lineNumber,
|
||||
metadata: { kind: 'comment', comment }
|
||||
}))
|
||||
if (draft) {
|
||||
annotations.push({
|
||||
side: 'additions',
|
||||
lineNumber: draft.lineNumber,
|
||||
metadata: { kind: 'draft', lineNumber: draft.lineNumber, startLine: draft.startLine }
|
||||
})
|
||||
}
|
||||
return annotations
|
||||
}
|
||||
|
||||
export function renderPierreDiffCommentAnnotation(
|
||||
annotation: PierreDiffCommentAnnotation,
|
||||
{
|
||||
worktreeId,
|
||||
filePath,
|
||||
activeGroupId,
|
||||
formatCommentPrompt,
|
||||
onDeleteComment,
|
||||
onUpdateComment,
|
||||
clearDeliveredDiffComments,
|
||||
draftPlaceholder,
|
||||
draftSubmitLabel,
|
||||
onCancelDraft,
|
||||
onSubmitDraft
|
||||
}: {
|
||||
worktreeId: string
|
||||
filePath: string
|
||||
activeGroupId: string
|
||||
formatCommentPrompt?: (comment: DecoratedDiffComment) => string
|
||||
onDeleteComment: (commentId: string) => void
|
||||
onUpdateComment?: (commentId: string, body: string) => Promise<boolean>
|
||||
clearDeliveredDiffComments: (
|
||||
worktreeId: string,
|
||||
comments: readonly DiffCommentDeliverySnapshot[]
|
||||
) => Promise<boolean>
|
||||
draftPlaceholder?: string
|
||||
draftSubmitLabel?: string
|
||||
onCancelDraft?: () => void
|
||||
onSubmitDraft?: (body: string) => Promise<void>
|
||||
}
|
||||
): React.ReactNode {
|
||||
const data = annotation.metadata
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
if (data.kind === 'draft') {
|
||||
return onSubmitDraft && onCancelDraft ? (
|
||||
<DiffCommentPopover
|
||||
lineNumber={data.lineNumber}
|
||||
startLine={data.startLine}
|
||||
placeholder={draftPlaceholder}
|
||||
submitLabel={draftSubmitLabel}
|
||||
submittingLabel="Posting…"
|
||||
layout="inline"
|
||||
onCancel={onCancelDraft}
|
||||
onSubmit={onSubmitDraft}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
const comment = data.comment
|
||||
|
||||
return (
|
||||
<DiffCommentCard
|
||||
lineNumber={comment.lineNumber}
|
||||
startLine={comment.startLine}
|
||||
label={comment.author ? getDiffCommentLineLabel(comment).toLowerCase() : undefined}
|
||||
body={comment.body}
|
||||
sentAt={comment.sentAt}
|
||||
author={comment.author}
|
||||
createdAtLabel={comment.createdAtLabel}
|
||||
url={comment.url}
|
||||
onDelete={comment.canDelete === false ? undefined : () => onDeleteComment(comment.id)}
|
||||
onSubmitEdit={
|
||||
onUpdateComment && comment.canEdit !== false
|
||||
? (body) => onUpdateComment(comment.id, body)
|
||||
: undefined
|
||||
}
|
||||
headerActions={
|
||||
worktreeId && comment.author === undefined ? (
|
||||
<NotesSendMenu
|
||||
worktreeId={worktreeId}
|
||||
groupId={activeGroupId}
|
||||
modeIdParts={['diff-comment-note', worktreeId, filePath, comment.id]}
|
||||
scopes={getSingleCommentSendScopes(comment, formatCommentPrompt)}
|
||||
targetModeLabel="This note"
|
||||
triggerClassName="orca-diff-comment-edit"
|
||||
disabledTooltip="Note already sent"
|
||||
onDelivered={(notes) => void clearDeliveredDiffComments(worktreeId, notes)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useCallback } from 'react'
|
||||
import { EditProvider } from '@pierre/diffs/react'
|
||||
import type { CreateEditor } from '@pierre/diffs/react'
|
||||
import type { DiffsEditor } from '@pierre/diffs'
|
||||
import { Editor, type EditorOptions } from '@pierre/diffs/edit'
|
||||
|
||||
/**
|
||||
* Supplies the editor factory every editable diff surface pulls from. Pierre
|
||||
* creates one editor per active session, so this stays a plain constructor —
|
||||
* per-section behavior belongs in each component's `editorOptions`.
|
||||
*/
|
||||
export function PierreDiffEditProvider({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const createEditor = useCallback<CreateEditor<undefined>>(
|
||||
(options: EditorOptions<undefined>): DiffsEditor<undefined> => new Editor(options),
|
||||
[]
|
||||
)
|
||||
|
||||
return <EditProvider createEditor={createEditor}>{children}</EditProvider>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { parseDiffFromFile, type FileContents, type FileDiffMetadata } from '@pierre/diffs'
|
||||
import type { CreatePatchOptionsNonabortable } from 'diff'
|
||||
|
||||
/** Statuses whose old side does not exist, so Pierre should render an add. */
|
||||
const ADDED_STATUSES = new Set(['added', 'untracked'])
|
||||
|
||||
function toFileContents(
|
||||
name: string,
|
||||
contents: string,
|
||||
cacheKey: string | undefined
|
||||
): FileContents {
|
||||
return cacheKey ? { name, contents, cacheKey } : { name, contents }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the diff Pierre renders from the two blobs git already handed us, so
|
||||
* no wire change is needed. `cacheKey` lets the worker pool reuse a rendered AST
|
||||
* across virtualization remounts — memoize the call on the same identity.
|
||||
*/
|
||||
export function buildPierreFileDiff({
|
||||
path,
|
||||
oldPath,
|
||||
status,
|
||||
originalContent,
|
||||
modifiedContent,
|
||||
cacheKey,
|
||||
parseDiffOptions
|
||||
}: {
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: string
|
||||
originalContent: string
|
||||
modifiedContent: string
|
||||
cacheKey?: string
|
||||
parseDiffOptions: CreatePatchOptionsNonabortable
|
||||
}): FileDiffMetadata {
|
||||
const isAdded = ADDED_STATUSES.has(status)
|
||||
const isDeleted = status === 'deleted'
|
||||
const oldFile = isAdded
|
||||
? null
|
||||
: toFileContents(oldPath ?? path, originalContent, cacheKey && `${cacheKey}:old`)
|
||||
const newFile = isDeleted
|
||||
? null
|
||||
: toFileContents(path, modifiedContent, cacheKey && `${cacheKey}:new`)
|
||||
|
||||
return parseDiffFromFile(oldFile, newFile, parseDiffOptions)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { FileDiffOptions } from '@pierre/diffs'
|
||||
import type { CreatePatchOptionsNonabortable } from 'diff'
|
||||
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
|
||||
import { computeDiffEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
|
||||
|
||||
/** Every field is optional: callers pass partial settings while the store hydrates. */
|
||||
export type PierreDiffSettings = Partial<
|
||||
Pick<
|
||||
GlobalSettings,
|
||||
| 'theme'
|
||||
| 'diffWordWrap'
|
||||
| 'diffShowWhitespace'
|
||||
| 'terminalFontSize'
|
||||
| 'editorFontFamily'
|
||||
| 'terminalFontFamily'
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* jsdiff options used when Pierre derives a diff from raw file contents.
|
||||
* Mirrors Monaco's `ignoreTrimWhitespace`: showing whitespace means we must
|
||||
* stop collapsing indentation-only changes.
|
||||
*/
|
||||
export function buildPierreParseDiffOptions(
|
||||
diffShowWhitespace: boolean | undefined
|
||||
): CreatePatchOptionsNonabortable {
|
||||
return { ignoreWhitespace: diffShowWhitespace !== true }
|
||||
}
|
||||
|
||||
export function buildPierreDiffOptions<LAnnotation>({
|
||||
settings,
|
||||
sideBySide,
|
||||
collapsed
|
||||
}: {
|
||||
settings?: PierreDiffSettings | null
|
||||
sideBySide: boolean
|
||||
collapsed?: boolean
|
||||
}): FileDiffOptions<LAnnotation> {
|
||||
return {
|
||||
diffStyle: sideBySide ? 'split' : 'unified',
|
||||
themeType: settings?.theme ?? 'system',
|
||||
overflow: settings?.diffWordWrap ? 'wrap' : 'scroll',
|
||||
parseDiffOptions: buildPierreParseDiffOptions(settings?.diffShowWhitespace),
|
||||
// Why: replaces Monaco's `hideUnchangedRegions`; context stays collapsed until expanded.
|
||||
expandUnchanged: false,
|
||||
collapsed,
|
||||
// Why: our own DiffSectionHeader / DiffViewer chrome already renders the file row.
|
||||
disableFileHeader: true,
|
||||
enableLineSelection: true,
|
||||
lineHoverHighlight: 'both'
|
||||
}
|
||||
}
|
||||
|
||||
/** Pierre reads typography from CSS variables rather than component options. */
|
||||
export function buildPierreDiffStyle(
|
||||
settings: PierreDiffSettings | null | undefined,
|
||||
editorFontZoomLevel: number
|
||||
): CSSProperties {
|
||||
const fontSize = computeDiffEditorFontSize(settings?.terminalFontSize ?? 13, editorFontZoomLevel)
|
||||
return {
|
||||
'--diffs-font-family': resolveEditorFontFamily(settings),
|
||||
'--diffs-font-size': `${fontSize}px`,
|
||||
'--diffs-line-height': `${Math.round(fontSize * 1.5)}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Pierre tags each rendered row with `data-line`, so a line scroll is a lookup
|
||||
* inside the shadow root. Virtualized rows may not exist yet, hence the
|
||||
* proportional fallback: land close, then let the caller retry once painted.
|
||||
*/
|
||||
export function scrollPierreDiffToLine({
|
||||
host,
|
||||
container,
|
||||
lineNumber,
|
||||
hunkIndex,
|
||||
hunkCount
|
||||
}: {
|
||||
host: HTMLElement | null
|
||||
container: HTMLElement | null
|
||||
lineNumber: number
|
||||
hunkIndex: number
|
||||
hunkCount: number
|
||||
}): boolean {
|
||||
if (!container) {
|
||||
return false
|
||||
}
|
||||
const row = host?.shadowRoot?.querySelector(`[data-line="${lineNumber}"]`)
|
||||
if (row instanceof HTMLElement) {
|
||||
const offset = row.getBoundingClientRect().top - container.getBoundingClientRect().top
|
||||
container.scrollTop += offset - container.clientHeight / 3
|
||||
return true
|
||||
}
|
||||
if (hunkCount > 0) {
|
||||
container.scrollTop = (hunkIndex / hunkCount) * container.scrollHeight
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ThemesType } from '@pierre/diffs'
|
||||
|
||||
/**
|
||||
* `light-plus` / `dark-plus` are the VS Code default themes that Monaco's
|
||||
* `vs` / `vs-dark` mirror, so swapping renderers keeps syntax colors stable.
|
||||
*/
|
||||
export const PIERRE_DIFF_THEMES: ThemesType = {
|
||||
light: 'light-plus',
|
||||
dark: 'dark-plus'
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from 'react'
|
||||
import { WorkerPoolContextProvider } from '@pierre/diffs/react'
|
||||
import type { WorkerInitializationRenderOptions, WorkerPoolOptions } from '@pierre/diffs/react'
|
||||
import { PIERRE_DIFF_THEMES } from './pierre-diff-theme'
|
||||
|
||||
// Why: Shiki grammars are heavy per worker; cap the pool well under Pierre's
|
||||
// default of 8 so a diff tab can't starve the terminal and agent threads.
|
||||
function resolvePoolSize(): number {
|
||||
const cores = navigator.hardwareConcurrency || 4
|
||||
return Math.min(4, Math.max(1, cores - 2))
|
||||
}
|
||||
|
||||
function createDiffHighlightWorker(): Worker {
|
||||
// Why: electron.vite.config.ts pins `worker.format: 'es'`, which this URL form requires.
|
||||
return new Worker(new URL('@pierre/diffs/worker/worker.js', import.meta.url), { type: 'module' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares one Shiki worker pool across every mounted diff surface. Pierre
|
||||
* reference-counts providers, so wrapping each lazy diff view keeps highlighting
|
||||
* off the main thread without paying worker startup on app launch.
|
||||
*/
|
||||
export function PierreDiffWorkerPoolProvider({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
const poolOptions = useMemo<WorkerPoolOptions>(
|
||||
() => ({ workerFactory: createDiffHighlightWorker, poolSize: resolvePoolSize() }),
|
||||
[]
|
||||
)
|
||||
// Why: the pool owns `theme` for every component instance; per-file options are ignored.
|
||||
const highlighterOptions = useMemo<WorkerInitializationRenderOptions>(
|
||||
() => ({ theme: PIERRE_DIFF_THEMES }),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkerPoolContextProvider poolOptions={poolOptions} highlighterOptions={highlighterOptions}>
|
||||
{children}
|
||||
</WorkerPoolContextProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { EditorFocusOptions } from '@pierre/diffs/edit'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { editorShortcutMatches } from '../editor-shortcuts'
|
||||
import { getDeepActiveElement } from './pierre-diff-active-element'
|
||||
|
||||
// Why: Pierre only ships its search panel with edit mode, and it has no
|
||||
// programmatic command dispatch — replay the shortcut its own listener expects.
|
||||
function dispatchPierreOpenSearchPanel(): void {
|
||||
const target = getDeepActiveElement()
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
const isMac = getShortcutPlatform() === 'darwin'
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'f',
|
||||
code: 'KeyF',
|
||||
metaKey: isMac,
|
||||
ctrlKey: !isMac,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Why: only `focus` is needed here, so stay structural and annotation-agnostic.
|
||||
type FocusableEditor = { focus: (options?: EditorFocusOptions) => void }
|
||||
|
||||
export type PierreDiffFind = {
|
||||
/** True when the surface should mount an edit session (real editing or find). */
|
||||
editEnabled: boolean
|
||||
/** Capture-phase keydown handler for the surface container. */
|
||||
handleContainerKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void
|
||||
/** Pass to `editorOptions.onAttach` so the panel opens on the first press. */
|
||||
handleEditorAttach: (editor: FocusableEditor) => void
|
||||
/** Leaves a find-only session so a read-only diff stops accepting input. */
|
||||
exitFind: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges our ⌘F keybinding onto Pierre's edit-mode search panel. On a
|
||||
* read-only diff the session exists only for find, and nothing is ever written
|
||||
* back, so dismissing it discards any stray keystrokes.
|
||||
*/
|
||||
export function usePierreDiffFind({ isEditable }: { isEditable: boolean }): PierreDiffFind {
|
||||
const [findActive, setFindActive] = useState(false)
|
||||
const pendingFindRef = useRef(false)
|
||||
|
||||
const handleContainerKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (findActive || !editorShortcutMatches('editor.find', event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
pendingFindRef.current = true
|
||||
setFindActive(true)
|
||||
},
|
||||
[findActive]
|
||||
)
|
||||
|
||||
const handleEditorAttach = useCallback((editor: FocusableEditor) => {
|
||||
if (!pendingFindRef.current) {
|
||||
return
|
||||
}
|
||||
pendingFindRef.current = false
|
||||
editor.focus({ lineNumber: 'first-visible', preventScroll: true })
|
||||
dispatchPierreOpenSearchPanel()
|
||||
}, [])
|
||||
|
||||
const exitFind = useCallback(() => {
|
||||
pendingFindRef.current = false
|
||||
setFindActive(false)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
editEnabled: isEditable || findActive,
|
||||
handleContainerKeyDown,
|
||||
handleEditorAttach,
|
||||
exitFind
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { monaco } from '@/lib/monaco-setup'
|
||||
import { disposeUnattachedMonacoModelPaths } from './diff-monaco-model-disposal'
|
||||
|
||||
// Why: virtualized section rows own Monaco model paths for their lifetime;
|
||||
// dispose on unmount/collapse so remounts do not leak detached models.
|
||||
export function useDiffSectionModelLifecycle(params: {
|
||||
modelPathBase: string
|
||||
collapsed: boolean
|
||||
}): {
|
||||
disposeDiffModels: () => void
|
||||
setSectionRootNode: (node: HTMLDivElement | null) => void
|
||||
} {
|
||||
const disposeDiffModels = useCallback(() => {
|
||||
window.setTimeout(() => {
|
||||
disposeUnattachedMonacoModelPaths(monaco, [
|
||||
`${params.modelPathBase}:original`,
|
||||
`${params.modelPathBase}:modified`
|
||||
])
|
||||
}, 0)
|
||||
}, [params.modelPathBase])
|
||||
const disposeDiffModelsRef = useRef(disposeDiffModels)
|
||||
// Keep callback-ref dispose path on the latest disposer without render-time mutation.
|
||||
useEffect(() => {
|
||||
disposeDiffModelsRef.current = disposeDiffModels
|
||||
}, [disposeDiffModels])
|
||||
|
||||
const setSectionRootNode = useCallback((node: HTMLDivElement | null): void => {
|
||||
if (node) {
|
||||
return
|
||||
}
|
||||
disposeDiffModelsRef.current()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (params.collapsed) {
|
||||
disposeDiffModels()
|
||||
}
|
||||
}, [disposeDiffModels, params.collapsed])
|
||||
|
||||
return { disposeDiffModels, setSectionRootNode }
|
||||
}
|
||||
@@ -2,12 +2,10 @@ import { useEffect } from 'react'
|
||||
import { removeDiffSectionMeasuredHeight } from './diff-section-height-cache'
|
||||
|
||||
export function useDiffSectionFallbackCleanup({
|
||||
disposeDiffModels,
|
||||
index,
|
||||
isLargeDiffLimited,
|
||||
setSectionHeights
|
||||
}: {
|
||||
disposeDiffModels: () => void
|
||||
index: number
|
||||
isLargeDiffLimited: boolean
|
||||
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
|
||||
@@ -15,7 +13,6 @@ export function useDiffSectionFallbackCleanup({
|
||||
useEffect(() => {
|
||||
if (isLargeDiffLimited) {
|
||||
setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index))
|
||||
disposeDiffModels()
|
||||
}
|
||||
}, [disposeDiffModels, index, isLargeDiffLimited, setSectionHeights])
|
||||
}, [index, isLargeDiffLimited, setSectionHeights])
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
const monacoFixture = vi.hoisted(() => {
|
||||
const models = new Map<
|
||||
string,
|
||||
{ dispose: ReturnType<typeof vi.fn>; isAttachedToEditor: () => boolean }
|
||||
>()
|
||||
return {
|
||||
models,
|
||||
monaco: {
|
||||
Uri: { parse: (value: string) => value },
|
||||
editor: {
|
||||
getModel: (path: string) => models.get(path) ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/monaco-setup', () => ({ monaco: monacoFixture.monaco }))
|
||||
|
||||
import { getDiffViewerMonacoModelPaths } from './diff-monaco-model-disposal'
|
||||
import { useDiffViewerLargeDiffLifecycle } from './useDiffViewerLargeDiffLifecycle'
|
||||
|
||||
function detachedModel(): {
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
isAttachedToEditor: () => boolean
|
||||
} {
|
||||
return { dispose: vi.fn(), isAttachedToEditor: () => false }
|
||||
}
|
||||
|
||||
function diffEditorFixture(
|
||||
original: unknown,
|
||||
modified: unknown
|
||||
): {
|
||||
current: editor.IStandaloneDiffEditor
|
||||
setModel: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
let models = { original, modified }
|
||||
const setModel = vi.fn((nextModels: typeof models) => {
|
||||
models = nextModels
|
||||
})
|
||||
return {
|
||||
current: {
|
||||
getModel: () => models,
|
||||
setModel
|
||||
} as unknown as editor.IStandaloneDiffEditor,
|
||||
setModel
|
||||
}
|
||||
}
|
||||
|
||||
describe('useDiffViewerLargeDiffLifecycle', () => {
|
||||
it('keeps repeated content rotations bounded to the current Monaco models', async () => {
|
||||
const modelKey = 'diff-tab'
|
||||
const originalModelKey = 'original-v1'
|
||||
const onEnterFallback = vi.fn()
|
||||
const paths = ['modified-v1', 'modified-v2', 'modified-v3'].map((modifiedModelKey) =>
|
||||
getDiffViewerMonacoModelPaths({
|
||||
modelKey,
|
||||
originalModelKey,
|
||||
modifiedModelKey,
|
||||
generationSuffix: ''
|
||||
})
|
||||
)
|
||||
const firstModel = detachedModel()
|
||||
const secondModel = detachedModel()
|
||||
const currentModel = detachedModel()
|
||||
const originalModel = detachedModel()
|
||||
monacoFixture.models.set(paths[0].originalModelPath, originalModel)
|
||||
monacoFixture.models.set(paths[0].modifiedModelPath, firstModel)
|
||||
monacoFixture.models.set(paths[1].modifiedModelPath, secondModel)
|
||||
monacoFixture.models.set(paths[2].modifiedModelPath, currentModel)
|
||||
const retainedEditor = diffEditorFixture(originalModel, firstModel)
|
||||
|
||||
const hook = renderHook(
|
||||
({ modifiedModelKey }) =>
|
||||
useDiffViewerLargeDiffLifecycle({
|
||||
limited: false,
|
||||
modelKey,
|
||||
originalModelKey,
|
||||
modifiedModelKey,
|
||||
diffEditorRef: retainedEditor,
|
||||
onEnterFallback
|
||||
}),
|
||||
{ initialProps: { modifiedModelKey: 'modified-v1' } }
|
||||
)
|
||||
|
||||
hook.rerender({ modifiedModelKey: 'modified-v2' })
|
||||
await act(() => Promise.resolve())
|
||||
hook.rerender({ modifiedModelKey: 'modified-v3' })
|
||||
await act(() => Promise.resolve())
|
||||
|
||||
expect(firstModel.dispose).toHaveBeenCalledOnce()
|
||||
expect(secondModel.dispose).toHaveBeenCalledOnce()
|
||||
expect(currentModel.dispose).not.toHaveBeenCalled()
|
||||
expect(retainedEditor.setModel).toHaveBeenCalledTimes(2)
|
||||
expect(hook.result.current).toEqual(paths[2])
|
||||
expect(onEnterFallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets the owning diff widget before disposing a superseded model', async () => {
|
||||
const modelKey = 'diff-tab'
|
||||
const paths = ['modified-v1', 'modified-v2'].map((modifiedModelKey) =>
|
||||
getDiffViewerMonacoModelPaths({
|
||||
modelKey,
|
||||
modifiedModelKey,
|
||||
generationSuffix: ''
|
||||
})
|
||||
)
|
||||
const supersededModel = detachedModel()
|
||||
const originalModel = detachedModel()
|
||||
const currentModel = detachedModel()
|
||||
monacoFixture.models.set(paths[0].originalModelPath, originalModel)
|
||||
monacoFixture.models.set(paths[0].modifiedModelPath, supersededModel)
|
||||
monacoFixture.models.set(paths[1].modifiedModelPath, currentModel)
|
||||
const retainedEditor = diffEditorFixture(originalModel, supersededModel)
|
||||
|
||||
const hook = renderHook(
|
||||
({ modifiedModelKey }) =>
|
||||
useDiffViewerLargeDiffLifecycle({
|
||||
limited: false,
|
||||
modelKey,
|
||||
modifiedModelKey,
|
||||
diffEditorRef: retainedEditor,
|
||||
onEnterFallback: vi.fn()
|
||||
}),
|
||||
{ initialProps: { modifiedModelKey: 'modified-v1' } }
|
||||
)
|
||||
|
||||
hook.rerender({ modifiedModelKey: 'modified-v2' })
|
||||
await act(() => Promise.resolve())
|
||||
|
||||
expect(retainedEditor.setModel).toHaveBeenCalledWith({
|
||||
original: originalModel,
|
||||
modified: currentModel
|
||||
})
|
||||
expect(supersededModel.dispose).toHaveBeenCalledOnce()
|
||||
expect(retainedEditor.setModel.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
supersededModel.dispose.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { monaco } from '@/lib/monaco-setup'
|
||||
import {
|
||||
disposeUnattachedDiffViewerMonacoModels,
|
||||
disposeUnattachedMonacoModelPaths,
|
||||
getDiffViewerMonacoModelPaths
|
||||
} from './diff-monaco-model-disposal'
|
||||
|
||||
type DiffViewerLargeDiffLifecycleInput = {
|
||||
limited: boolean
|
||||
modelKey: string
|
||||
originalModelKey?: string
|
||||
modifiedModelKey?: string
|
||||
diffEditorRef: RefObject<editor.IStandaloneDiffEditor | null>
|
||||
onEnterFallback: () => void
|
||||
}
|
||||
|
||||
export function useDiffViewerLargeDiffLifecycle({
|
||||
limited,
|
||||
modelKey,
|
||||
originalModelKey,
|
||||
modifiedModelKey,
|
||||
diffEditorRef,
|
||||
onEnterFallback
|
||||
}: DiffViewerLargeDiffLifecycleInput): {
|
||||
originalModelPath: string
|
||||
modifiedModelPath: string
|
||||
} {
|
||||
const [largeDiffModelGeneration, setLargeDiffModelGeneration] = useState(0)
|
||||
const largeDiffModelGenerationSuffix =
|
||||
largeDiffModelGeneration === 0 ? '' : `:large-diff-generation:${largeDiffModelGeneration}`
|
||||
const currentDiffModelPaths = useMemo(
|
||||
() =>
|
||||
getDiffViewerMonacoModelPaths({
|
||||
modelKey,
|
||||
originalModelKey,
|
||||
modifiedModelKey,
|
||||
generationSuffix: largeDiffModelGenerationSuffix
|
||||
}),
|
||||
[modelKey, originalModelKey, modifiedModelKey, largeDiffModelGenerationSuffix]
|
||||
)
|
||||
const currentDiffModelPathsRef = useRef(currentDiffModelPaths)
|
||||
currentDiffModelPathsRef.current = currentDiffModelPaths
|
||||
const previousDiffModelPathsRef = useRef(currentDiffModelPaths)
|
||||
|
||||
useEffect(() => {
|
||||
const previousModelPaths = previousDiffModelPathsRef.current
|
||||
previousDiffModelPathsRef.current = currentDiffModelPaths
|
||||
const supersededModelPaths = [
|
||||
previousModelPaths.originalModelPath !== currentDiffModelPaths.originalModelPath
|
||||
? previousModelPaths.originalModelPath
|
||||
: null,
|
||||
previousModelPaths.modifiedModelPath !== currentDiffModelPaths.modifiedModelPath
|
||||
? previousModelPaths.modifiedModelPath
|
||||
: null
|
||||
].filter((modelPath): modelPath is string => modelPath !== null)
|
||||
if (supersededModelPaths.length === 0) {
|
||||
return
|
||||
}
|
||||
const diffEditor = diffEditorRef.current
|
||||
if (diffEditor) {
|
||||
const originalModel = monaco.editor.getModel(
|
||||
monaco.Uri.parse(currentDiffModelPaths.originalModelPath)
|
||||
)
|
||||
const modifiedModel = monaco.editor.getModel(
|
||||
monaco.Uri.parse(currentDiffModelPaths.modifiedModelPath)
|
||||
)
|
||||
if (!originalModel || !modifiedModel) {
|
||||
return
|
||||
}
|
||||
const activeModels = diffEditor.getModel()
|
||||
if (activeModels?.original !== originalModel || activeModels.modified !== modifiedModel) {
|
||||
// Why: @monaco-editor/react swaps the two child models separately, but
|
||||
// Monaco's diff widget must release its old pair before either is disposed.
|
||||
diffEditor.setModel({ original: originalModel, modified: modifiedModel })
|
||||
}
|
||||
}
|
||||
disposeUnattachedMonacoModelPaths(monaco, supersededModelPaths)
|
||||
}, [currentDiffModelPaths, diffEditorRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!limited) {
|
||||
return
|
||||
}
|
||||
const modelPathsToDispose = currentDiffModelPathsRef.current
|
||||
// Why: rotate below-limit Monaco paths after a safety fallback so stale
|
||||
// large models cannot be reused when the same diff shrinks back down.
|
||||
setLargeDiffModelGeneration((generation) => generation + 1)
|
||||
onEnterFallback()
|
||||
// Why: ordinary tab switches keep models for fast return; the safety
|
||||
// fallback must instead release huge detached models after unmount cleanup.
|
||||
const disposeTimer = window.setTimeout(() => {
|
||||
disposeUnattachedDiffViewerMonacoModels(monaco, modelPathsToDispose)
|
||||
}, 0)
|
||||
return () => window.clearTimeout(disposeTimer)
|
||||
}, [limited, onEnterFallback])
|
||||
|
||||
return currentDiffModelPaths
|
||||
}
|
||||
-7
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import { DiffSectionItem } from '@/components/editor/DiffSectionItem'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment'
|
||||
@@ -31,7 +30,6 @@ export function PRFilesCombinedDiffBody({
|
||||
scrollContainerRef,
|
||||
virtualizer,
|
||||
sections,
|
||||
isDark,
|
||||
settings,
|
||||
sectionHeights,
|
||||
inlineReviewComments,
|
||||
@@ -43,7 +41,6 @@ export function PRFilesCombinedDiffBody({
|
||||
handleAddLineComment,
|
||||
setSectionHeights,
|
||||
setSections,
|
||||
modifiedEditorsRef,
|
||||
handleSectionSaveRef,
|
||||
getCommentableLineNumbers
|
||||
}: {
|
||||
@@ -65,7 +62,6 @@ export function PRFilesCombinedDiffBody({
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>
|
||||
sections: DiffSection[]
|
||||
isDark: boolean
|
||||
settings: DiffSectionItemProps['settings']
|
||||
sectionHeights: Record<number, number>
|
||||
inlineReviewComments: DecoratedDiffComment[]
|
||||
@@ -82,7 +78,6 @@ export function PRFilesCombinedDiffBody({
|
||||
getCommentableLineNumbers: (section: DiffSection) => readonly number[] | undefined
|
||||
setSectionHeights: React.Dispatch<React.SetStateAction<Record<number, number>>>
|
||||
setSections: React.Dispatch<React.SetStateAction<DiffSection[]>>
|
||||
modifiedEditorsRef: React.RefObject<Map<number, monacoEditor.IStandaloneCodeEditor>>
|
||||
handleSectionSaveRef: React.MutableRefObject<(index: number) => Promise<void>>
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
@@ -128,7 +123,6 @@ export function PRFilesCombinedDiffBody({
|
||||
index={virtualItem.index}
|
||||
isBranchMode={false}
|
||||
sideBySide={sideBySide}
|
||||
isDark={isDark}
|
||||
settings={settings}
|
||||
sectionHeight={sectionHeights[virtualItem.index]}
|
||||
worktreeId={`github-pr:${repoId}:${prNumber}`}
|
||||
@@ -154,7 +148,6 @@ export function PRFilesCombinedDiffBody({
|
||||
getCommentableLineNumbers={getCommentableLineNumbers}
|
||||
setSectionHeights={setSectionHeights}
|
||||
setSections={setSections}
|
||||
modifiedEditorsRef={modifiedEditorsRef}
|
||||
handleSectionSaveRef={handleSectionSaveRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+3
-7
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment'
|
||||
import { useCombinedDiffSectionIndexMap } from '../../editor/combined-diff/resolve-changes/use-combined-diff-section-index-map'
|
||||
import { handleCombinedDiffFileTreeNavigation } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-navigation'
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
setAllPRFilesCombinedDiffSectionsCollapsed,
|
||||
togglePRFilesCombinedDiffSection
|
||||
} from './pr-files-combined-diff-load'
|
||||
import { PierreDiffProviders } from '@/components/editor/pierre-diff/PierreDiffProviders'
|
||||
|
||||
type PRFilesCombinedDiffSectionsProps = PRFilesCombinedDiffViewerProps & {
|
||||
signature: string
|
||||
@@ -92,9 +92,6 @@ function PRFilesCombinedDiffSections({
|
||||
setFileTreeCollapsed
|
||||
}: PRFilesCombinedDiffSectionsProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const isDark =
|
||||
settings?.theme === 'dark' ||
|
||||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
// Why: this subtree is keyed by the diff signature, so its file set is fixed for the
|
||||
// mount. Freezing it in state keeps a stable identity without caching through a ref.
|
||||
const [entries] = useState<GitBranchChangeEntry[]>(() =>
|
||||
@@ -161,7 +158,6 @@ function PRFilesCombinedDiffSections({
|
||||
const loadedIndicesRef = useRef<Set<number>>(new Set())
|
||||
const loadingIndicesRef = useRef<Set<number>>(new Set())
|
||||
const sectionsRef = useRef<DiffSection[]>(sections)
|
||||
const modifiedEditorsRef = useRef<Map<number, monacoEditor.IStandaloneCodeEditor>>(new Map())
|
||||
const handleSectionSaveRef = useRef<(index: number) => Promise<void>>(async () => {})
|
||||
|
||||
// Why: commit-phase write (a render React abandons would leak one), and it must be a layout
|
||||
@@ -333,6 +329,7 @@ function PRFilesCombinedDiffSections({
|
||||
)
|
||||
|
||||
return (
|
||||
<PierreDiffProviders>
|
||||
<PRFilesCombinedDiffBody
|
||||
files={files}
|
||||
repoPath={repoPath}
|
||||
@@ -352,7 +349,6 @@ function PRFilesCombinedDiffSections({
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
virtualizer={virtualizer}
|
||||
sections={sections}
|
||||
isDark={isDark}
|
||||
settings={settings}
|
||||
sectionHeights={sectionHeights}
|
||||
inlineReviewComments={inlineReviewComments}
|
||||
@@ -365,8 +361,8 @@ function PRFilesCombinedDiffSections({
|
||||
getCommentableLineNumbers={getCommentableLineNumbers}
|
||||
setSectionHeights={setSectionHeights}
|
||||
setSections={setSections}
|
||||
modifiedEditorsRef={modifiedEditorsRef}
|
||||
handleSectionSaveRef={handleSectionSaveRef}
|
||||
/>
|
||||
</PierreDiffProviders>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import type { editor as monacoEditor } from 'monaco-editor'
|
||||
import { useAppStore } from '@/store'
|
||||
import { DiffSectionItem } from '@/components/editor/DiffSectionItem'
|
||||
import { CombinedDiffFileTree } from '../../editor/combined-diff/browse-files/combined-diff-file-tree'
|
||||
@@ -28,6 +27,7 @@ import { usePRFilesDiffViewPersistence } from './view-restore'
|
||||
import { buildInlineReviewComments } from './inline-comments'
|
||||
import { usePRFileSectionHeights } from './section-heights'
|
||||
import { usePRFileActiveSection } from './active-section'
|
||||
import { PierreDiffProviders } from '@/components/editor/pierre-diff/PierreDiffProviders'
|
||||
|
||||
export function PRFilesCombinedDiffViewer({
|
||||
files,
|
||||
@@ -45,9 +45,6 @@ export function PRFilesCombinedDiffViewer({
|
||||
onViewedChange
|
||||
}: PRFilesCombinedDiffViewerProps): React.JSX.Element {
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const isDark =
|
||||
settings?.theme === 'dark' ||
|
||||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const diffEntrySignature = useMemo(
|
||||
() =>
|
||||
JSON.stringify(
|
||||
@@ -116,7 +113,6 @@ export function PRFilesCombinedDiffViewer({
|
||||
const loadingIndicesRef = useRef<Set<number>>(new Set())
|
||||
const sectionsRef = useRef<DiffSection[]>([])
|
||||
const generationRef = useRef(0)
|
||||
const modifiedEditorsRef = useRef<Map<number, monacoEditor.IStandaloneCodeEditor>>(new Map())
|
||||
const handleSectionSaveRef = useRef<(index: number) => Promise<void>>(async () => {})
|
||||
useLayoutEffect(() => {
|
||||
// Why: keep the loader/navigation callbacks reading the latest sections without a render-phase ref write.
|
||||
@@ -308,6 +304,7 @@ export function PRFilesCombinedDiffViewer({
|
||||
)
|
||||
|
||||
return (
|
||||
<PierreDiffProviders>
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<PRFilesDiffToolbar
|
||||
files={files}
|
||||
@@ -350,7 +347,6 @@ export function PRFilesCombinedDiffViewer({
|
||||
index={virtualItem.index}
|
||||
isBranchMode={false}
|
||||
sideBySide={sideBySide}
|
||||
isDark={isDark}
|
||||
settings={settings}
|
||||
sectionHeight={sectionHeights[virtualItem.index]}
|
||||
worktreeId={`github-pr:${repoId}:${prNumber}`}
|
||||
@@ -367,7 +363,6 @@ export function PRFilesCombinedDiffViewer({
|
||||
getCommentableLineNumbers={getCommentableLineNumbers}
|
||||
setSectionHeights={setSectionHeights}
|
||||
setSections={setSections}
|
||||
modifiedEditorsRef={modifiedEditorsRef}
|
||||
handleSectionSaveRef={handleSectionSaveRef}
|
||||
/>
|
||||
</div>
|
||||
@@ -377,5 +372,6 @@ export function PRFilesCombinedDiffViewer({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PierreDiffProviders>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { editor } from 'monaco-editor'
|
||||
import {
|
||||
guardMonacoDiffEditorDispose,
|
||||
installMonacoDiffEditorDisposalGuard
|
||||
} from './monaco-diff-editor-disposal'
|
||||
|
||||
function createMockDiffEditor(dispose: () => void): editor.IStandaloneDiffEditor {
|
||||
return { dispose } as unknown as editor.IStandaloneDiffEditor
|
||||
}
|
||||
|
||||
describe('guardMonacoDiffEditorDispose', () => {
|
||||
it('contains Monaco disposal errors after invoking the real dispose path', () => {
|
||||
const disposeError = new AggregateError(
|
||||
[new Error('inner dispose failed')],
|
||||
'Encountered errors while disposing of store'
|
||||
)
|
||||
const reportError = vi.fn()
|
||||
const originalDispose = vi.fn(() => {
|
||||
throw disposeError
|
||||
})
|
||||
const diffEditor = createMockDiffEditor(originalDispose)
|
||||
|
||||
guardMonacoDiffEditorDispose(diffEditor, reportError)
|
||||
|
||||
expect(() => diffEditor.dispose()).not.toThrow()
|
||||
expect(originalDispose).toHaveBeenCalledTimes(1)
|
||||
expect(reportError).toHaveBeenCalledWith(disposeError)
|
||||
})
|
||||
|
||||
it('does not repeatedly dispose an editor after the guarded disposal has run', () => {
|
||||
const originalDispose = vi.fn()
|
||||
const diffEditor = createMockDiffEditor(originalDispose)
|
||||
|
||||
guardMonacoDiffEditorDispose(diffEditor)
|
||||
diffEditor.dispose()
|
||||
diffEditor.dispose()
|
||||
|
||||
expect(originalDispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installMonacoDiffEditorDisposalGuard', () => {
|
||||
it('wraps diff editors created by Monaco and keeps factory installation idempotent', () => {
|
||||
const disposeError = new AggregateError(
|
||||
[new Error('inner dispose failed')],
|
||||
'Encountered errors while disposing of store'
|
||||
)
|
||||
const reportError = vi.fn()
|
||||
const originalDispose = vi.fn(() => {
|
||||
throw disposeError
|
||||
})
|
||||
const createDiffEditor = vi.fn((_element: HTMLElement) => createMockDiffEditor(originalDispose))
|
||||
const monaco = {
|
||||
editor: {
|
||||
createDiffEditor
|
||||
}
|
||||
}
|
||||
|
||||
installMonacoDiffEditorDisposalGuard(monaco, reportError)
|
||||
installMonacoDiffEditorDisposalGuard(monaco, reportError)
|
||||
|
||||
const diffEditor = monaco.editor.createDiffEditor({} as HTMLElement)
|
||||
|
||||
expect(createDiffEditor).toHaveBeenCalledTimes(1)
|
||||
expect(() => diffEditor.dispose()).not.toThrow()
|
||||
expect(originalDispose).toHaveBeenCalledTimes(1)
|
||||
expect(reportError).toHaveBeenCalledWith(disposeError)
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
|
||||
type CreateDiffEditor = (
|
||||
domElement: HTMLElement,
|
||||
options?: editor.IStandaloneDiffEditorConstructionOptions,
|
||||
override?: editor.IEditorOverrideServices
|
||||
) => editor.IStandaloneDiffEditor
|
||||
|
||||
type MonacoDiffEditorNamespace = {
|
||||
editor: {
|
||||
createDiffEditor: CreateDiffEditor
|
||||
}
|
||||
}
|
||||
|
||||
type GuardedDiffEditor = editor.IStandaloneDiffEditor & {
|
||||
__orcaDiffEditorDisposeGuardInstalled?: true
|
||||
}
|
||||
|
||||
type GuardedEditorNamespace = MonacoDiffEditorNamespace['editor'] & {
|
||||
__orcaDiffEditorFactoryGuardInstalled?: true
|
||||
}
|
||||
|
||||
type DisposeErrorReporter = (error: unknown) => void
|
||||
|
||||
function reportMonacoDiffDisposeError(error: unknown): void {
|
||||
console.warn('[monaco] Diff editor disposal threw after teardown was requested', error)
|
||||
}
|
||||
|
||||
export function guardMonacoDiffEditorDispose(
|
||||
diffEditor: editor.IStandaloneDiffEditor,
|
||||
reportError: DisposeErrorReporter = reportMonacoDiffDisposeError
|
||||
): editor.IStandaloneDiffEditor {
|
||||
const guardedDiffEditor = diffEditor as GuardedDiffEditor
|
||||
if (guardedDiffEditor.__orcaDiffEditorDisposeGuardInstalled) {
|
||||
return diffEditor
|
||||
}
|
||||
|
||||
const originalDispose = diffEditor.dispose.bind(diffEditor)
|
||||
let didDispose = false
|
||||
|
||||
guardedDiffEditor.dispose = () => {
|
||||
if (didDispose) {
|
||||
return
|
||||
}
|
||||
didDispose = true
|
||||
|
||||
try {
|
||||
originalDispose()
|
||||
} catch (error) {
|
||||
// Why: Monaco's DisposableStore throws AggregateError after attempting
|
||||
// teardown; letting it escape React cleanup can crash the renderer.
|
||||
reportError(error)
|
||||
}
|
||||
}
|
||||
guardedDiffEditor.__orcaDiffEditorDisposeGuardInstalled = true
|
||||
|
||||
return diffEditor
|
||||
}
|
||||
|
||||
export function installMonacoDiffEditorDisposalGuard(
|
||||
monaco: MonacoDiffEditorNamespace,
|
||||
reportError?: DisposeErrorReporter
|
||||
): void {
|
||||
const editorNamespace = monaco.editor as GuardedEditorNamespace
|
||||
if (editorNamespace.__orcaDiffEditorFactoryGuardInstalled) {
|
||||
return
|
||||
}
|
||||
|
||||
const createDiffEditor = editorNamespace.createDiffEditor.bind(editorNamespace)
|
||||
editorNamespace.createDiffEditor = ((...args: Parameters<CreateDiffEditor>) =>
|
||||
guardMonacoDiffEditorDispose(createDiffEditor(...args), reportError)) as CreateDiffEditor
|
||||
editorNamespace.__orcaDiffEditorFactoryGuardInstalled = true
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { registerNimLanguage } from './monaco-languages/register-nim'
|
||||
import { registerSvelteLanguage } from './monaco-languages/register-svelte'
|
||||
import { registerVueLanguage } from './monaco-languages/register-vue'
|
||||
import { installMonacoDelayerCancellationGuard } from './monaco-delayer-cancellation-guard'
|
||||
import { installMonacoDiffEditorDisposalGuard } from './monaco-diff-editor-disposal'
|
||||
import { installMonacoPeekReferencesPreviewOptions } from './monaco-peek-preview-options'
|
||||
import { installMonacoContextMenuPaste } from '@/components/editor/install-monaco-context-menu-paste'
|
||||
|
||||
@@ -80,7 +79,6 @@ registerAstroLanguage(monaco)
|
||||
registerNimLanguage(monaco)
|
||||
registerJsonlLanguage(monaco)
|
||||
installMonacoDelayerCancellationGuard()
|
||||
installMonacoDiffEditorDisposalGuard(monaco)
|
||||
installMonacoPeekReferencesPreviewOptions()
|
||||
// Why: Monaco's built-in context-menu Paste reads navigator.clipboard, which is
|
||||
// blocked in Orca's sandboxed renderer. Route it through the trusted IPC bridge
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import {
|
||||
editorSelectionCache,
|
||||
diffViewStateCache,
|
||||
diffScrollTopCache,
|
||||
pdfViewPositionCache,
|
||||
setWithLRU,
|
||||
scrollTopCache
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
beforeEach(() => {
|
||||
scrollTopCache.clear()
|
||||
editorSelectionCache.clear()
|
||||
diffViewStateCache.clear()
|
||||
diffScrollTopCache.clear()
|
||||
pdfViewPositionCache.clear()
|
||||
})
|
||||
|
||||
@@ -185,28 +185,16 @@ describe('pdfViewPositionCache', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffViewStateCache', () => {
|
||||
describe('diffScrollTopCache', () => {
|
||||
it('is an empty Map on import', () => {
|
||||
expect(diffViewStateCache).toBeInstanceOf(Map)
|
||||
expect(diffViewStateCache.size).toBe(0)
|
||||
expect(diffScrollTopCache).toBeInstanceOf(Map)
|
||||
expect(diffScrollTopCache.size).toBe(0)
|
||||
})
|
||||
|
||||
it('works with setWithLRU for diff-tab keys', () => {
|
||||
const diffState = {
|
||||
original: {
|
||||
cursorState: [],
|
||||
viewState: { scrollTop: 10, scrollTopWithoutViewZones: 10, scrollLeft: 0 }
|
||||
},
|
||||
modified: {
|
||||
cursorState: [],
|
||||
viewState: { scrollTop: 20, scrollTopWithoutViewZones: 20, scrollLeft: 0 }
|
||||
},
|
||||
modelState: { unchangedRegions: [] }
|
||||
} as unknown as typeof diffViewStateCache extends Map<string, infer T> ? T : never
|
||||
setWithLRU(diffScrollTopCache, 'diff-tab', 420)
|
||||
|
||||
setWithLRU(diffViewStateCache, 'diff-tab', diffState)
|
||||
|
||||
expect(diffViewStateCache.get('diff-tab')).toBe(diffState)
|
||||
expect(diffViewStateCache.size).toBe(1)
|
||||
expect(diffScrollTopCache.get('diff-tab')).toBe(420)
|
||||
expect(diffScrollTopCache.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { editor, ISelection } from 'monaco-editor'
|
||||
import type { ISelection } from 'monaco-editor'
|
||||
|
||||
// Why: 20 entries covers a typical working set of open/recently-viewed files.
|
||||
// Eviction only means losing a scroll position (user sees top of file), not a
|
||||
@@ -46,8 +46,6 @@ export const editorSelectionCache = new Map<string, readonly ISelection[]>()
|
||||
export type PdfViewPosition = { pageNumber: number; top: number; left: number }
|
||||
export const pdfViewPositionCache = new Map<string, PdfViewPosition>()
|
||||
|
||||
// Why: Diff editors need more than a numeric scroll offset to restore the same
|
||||
// working context. Monaco's diff view state also carries cursor/selection state
|
||||
// for both sides plus diff model state, which matches VS Code's restore path
|
||||
// more closely than Orca's previous scroll-only cache.
|
||||
export const diffViewStateCache = new Map<string, editor.IDiffEditorViewState>()
|
||||
// Why: diff tabs keep their own scroll map, keyed by tab identity rather than
|
||||
// file path so two live diffs of one file don't restore onto each other.
|
||||
export const diffScrollTopCache = new Map<string, number>()
|
||||
|
||||
Reference in New Issue
Block a user