From e022d285ea0c247ab4dd2a988288abb6cdba641d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:17:06 -0700 Subject: [PATCH] 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. --- package.json | 1 + pnpm-lock.yaml | 220 +++++++ src/renderer/src/assets/main.css | 8 + .../diff-comments/DiffCommentPopover.tsx | 29 +- .../diff-comments/diff-comment-send-scopes.ts | 22 + .../src/components/editor/DiffSectionBody.tsx | 92 +-- .../src/components/editor/DiffSectionItem.tsx | 428 ++++++-------- .../src/components/editor/DiffViewer.tsx | 546 ++++++------------ .../editor/closed-editor-tab-disposal.test.ts | 12 +- .../editor/closed-editor-tab-disposal.ts | 10 +- .../combined-diff/CombinedDiffViewer.tsx | 39 +- .../combined-diff-load-scheduler.test.ts | 19 +- .../combined-diff-load-scheduler.ts | 6 +- .../use-combined-diff-section-actions.ts | 11 +- .../combined-diff-section-list.tsx | 6 - .../diff-editor-line-number-options.test.ts | 81 --- .../editor/diff-editor-line-number-options.ts | 54 -- .../editor/diff-editor-scrollbar-options.ts | 18 - .../diff-editor-whitespace-options.test.ts | 13 - .../editor/diff-editor-whitespace-options.ts | 10 - .../diff-editor-word-wrap-options.test.ts | 13 - .../editor/diff-editor-word-wrap-options.ts | 9 - .../editor/diff-model-swap-view-state.test.ts | 90 --- .../editor/diff-model-swap-view-state.ts | 52 -- .../editor/diff-navigation-context.test.tsx | 179 +++--- .../editor/diff-navigation-context.tsx | 135 +++-- .../editor/diff-section-item-props.ts | 5 +- .../editor/diff-section-live-render-limit.ts | 11 +- .../editor/editor-shortcuts.test.ts | 9 +- .../src/components/editor/editor-shortcuts.ts | 16 +- .../pierre-diff/PierreDiffProviders.tsx | 19 + .../editor/pierre-diff/PierreDiffSurface.tsx | 155 +++++ .../pierre-diff/pierre-diff-active-element.ts | 11 + .../pierre-diff-comment-annotations.tsx | 124 ++++ .../pierre-diff-editor-provider.tsx | 23 + .../pierre-diff/pierre-diff-metadata.ts | 47 ++ .../editor/pierre-diff/pierre-diff-options.ts | 66 +++ .../editor/pierre-diff/pierre-diff-scroll.ts | 32 + .../editor/pierre-diff/pierre-diff-theme.ts | 10 + .../pierre-diff/pierre-diff-worker-pool.tsx | 43 ++ .../pierre-diff/use-pierre-diff-find.ts | 84 +++ .../use-diff-section-model-lifecycle.ts | 42 -- .../editor/useDiffSectionFallbackCleanup.ts | 5 +- .../useDiffViewerLargeDiffLifecycle.test.tsx | 144 ----- .../editor/useDiffViewerLargeDiffLifecycle.ts | 101 ---- .../pr-files-combined-diff-body.tsx | 7 - .../pr-files-combined-diff-viewer.tsx | 10 +- .../files/combined-diff-viewer.tsx | 10 +- .../lib/monaco-diff-editor-disposal.test.ts | 70 --- .../src/lib/monaco-diff-editor-disposal.ts | 73 --- src/renderer/src/lib/monaco-setup.ts | 2 - src/renderer/src/lib/scroll-cache.test.ts | 28 +- src/renderer/src/lib/scroll-cache.ts | 10 +- 53 files changed, 1489 insertions(+), 1771 deletions(-) create mode 100644 src/renderer/src/components/diff-comments/diff-comment-send-scopes.ts delete mode 100644 src/renderer/src/components/editor/diff-editor-line-number-options.test.ts delete mode 100644 src/renderer/src/components/editor/diff-editor-line-number-options.ts delete mode 100644 src/renderer/src/components/editor/diff-editor-scrollbar-options.ts delete mode 100644 src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts delete mode 100644 src/renderer/src/components/editor/diff-editor-whitespace-options.ts delete mode 100644 src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts delete mode 100644 src/renderer/src/components/editor/diff-editor-word-wrap-options.ts delete mode 100644 src/renderer/src/components/editor/diff-model-swap-view-state.test.ts delete mode 100644 src/renderer/src/components/editor/diff-model-swap-view-state.ts create mode 100644 src/renderer/src/components/editor/pierre-diff/PierreDiffProviders.tsx create mode 100644 src/renderer/src/components/editor/pierre-diff/PierreDiffSurface.tsx create mode 100644 src/renderer/src/components/editor/pierre-diff/pierre-diff-active-element.ts create mode 100644 src/renderer/src/components/editor/pierre-diff/pierre-diff-comment-annotations.tsx create mode 100644 src/renderer/src/components/editor/pierre-diff/pierre-diff-editor-provider.tsx create mode 100644 src/renderer/src/components/editor/pierre-diff/pierre-diff-metadata.ts create mode 100644 src/renderer/src/components/editor/pierre-diff/pierre-diff-options.ts create mode 100644 src/renderer/src/components/editor/pierre-diff/pierre-diff-scroll.ts create mode 100644 src/renderer/src/components/editor/pierre-diff/pierre-diff-theme.ts create mode 100644 src/renderer/src/components/editor/pierre-diff/pierre-diff-worker-pool.tsx create mode 100644 src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.ts delete mode 100644 src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts delete mode 100644 src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx delete mode 100644 src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.ts delete mode 100644 src/renderer/src/lib/monaco-diff-editor-disposal.test.ts delete mode 100644 src/renderer/src/lib/monaco-diff-editor-disposal.ts diff --git a/package.json b/package.json index 7b27dffc8c7..272db205f14 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a69e47f89b3..e7073893c66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 3527f11c44e..8312a6a3a9d 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; +} diff --git a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx index 235c572216f..c31cd4867a4 100644 --- a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx +++ b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx @@ -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 (
string +): NotesSendMenuScope[] { + return [ + { + id: 'note', + label: translate( + 'auto.components.diff.comments.useDiffCommentDecorator.995fa28b50', + 'This note' + ), + notes: comment.sentAt ? [] : [comment], + prompt: formatCommentPrompt ? formatCommentPrompt(comment) : formatDiffComments([comment]) + } + ] +} diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx index 78d2a7ff896..26c1351488b 100644 --- a/src/renderer/src/components/editor/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/DiffSectionBody.tsx @@ -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 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 + /** 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. - - ) : null} {section.loadOnDemand ? ( ) : ( - + renderDiff() )}
) diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index c9b67bc92dc..7fe465be1d3 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -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(null) - const diffEditorRef = useRef(null) - const sectionBodyRef = useRef(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(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 => { - 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 => { + 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( + () => ( + setPendingComment(null)} + onSubmitComment={handleSubmitComment} + /> + ), + [ + addLineCommentLabel, + addLineCommentPlaceholder, + comments, + fileDiff, + handleDeleteComment, + handleAddComment, + handleEditChange, + handlePostRender, + handleSubmitComment, + handleUpdateComment, + hasLineCommentAction, + isEditable, + pendingComment, + section.path, + settings, + sideBySide, + worktreeId + ] + ) + return ( -
+
setPopover(null)} - onSubmitComment={handleSubmitComment} + renderDiff={renderDiff} onRetrySection={retrySection} onLoadDeferredSection={loadDeferredSection ?? loadSection} onSaveLimitedDiff={() => void handleSectionSaveRef.current(index)} - onMount={handleMount} /> )}
diff --git a/src/renderer/src/components/editor/DiffViewer.tsx b/src/renderer/src/components/editor/DiffViewer.tsx index 2c09fdf23f3..ff827050c95 100644 --- a/src/renderer/src/components/editor/DiffViewer.tsx +++ b/src/renderer/src/components/editor/DiffViewer.tsx @@ -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(null) - const { registerDiffEditor, unregisterDiffEditor } = useDiffEditorRegistration() - const diffBodyRef = useRef(null) - const lineNumberOptionsSubRef = useRef<{ dispose: () => void } | null>(null) - const [modifiedEditor, setModifiedEditor] = useState(null) - const [popover, setPopover] = useState<{ + const scrollContainerRef = useRef(null) + const pierreHostRef = useRef(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 => { - 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 => { + 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 (
-
- {popover && hasLineCommentAction && !renderLimit.limited && ( - setPopover(null)} - onSubmit={handleSubmitComment} - /> - )} +
{renderLimit.limited ? ( ) : ( - + + setPendingComment(null)} + onSubmitComment={handleSubmitComment} + /> + )}
- {toastNode}
) } diff --git a/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts b/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts index f23876fcf35..1f9e61af82c 100644 --- a/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts +++ b/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts @@ -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) }) diff --git a/src/renderer/src/components/editor/closed-editor-tab-disposal.ts b/src/renderer/src/components/editor/closed-editor-tab-disposal.ts index ddde3a74502..07645540803 100644 --- a/src/renderer/src/components/editor/closed-editor-tab-disposal.ts +++ b/src/renderer/src/components/editor/closed-editor-tab-disposal.ts @@ -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) } diff --git a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx index 82e7ece6cfb..93fbb32e19c 100644 --- a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx @@ -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([]) const [sectionHeights, setSectionHeights] = useState>({}) @@ -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 ( - <> +
- + ) } diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts index a982d6b692c..7cba626f283 100644 --- a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts @@ -15,8 +15,10 @@ async function flushMicrotasks(): Promise { } 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() }) diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.ts index 61c480dd716..9e35fbce155 100644 --- a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.ts @@ -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 schedule?: (callback: () => void) => void diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts index 0fa71dbf136..ba0d77cc4dc 100644 --- a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts @@ -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>(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 } } diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx index 36e06efcf78..df8b016b967 100644 --- a/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx @@ -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) => 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 diff --git a/src/renderer/src/components/editor/diff-editor-line-number-options.test.ts b/src/renderer/src/components/editor/diff-editor-line-number-options.test.ts deleted file mode 100644 index 57d79eb8c39..00000000000 --- a/src/renderer/src/components/editor/diff-editor-line-number-options.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/renderer/src/components/editor/diff-editor-line-number-options.ts b/src/renderer/src/components/editor/diff-editor-line-number-options.ts deleted file mode 100644 index 8a4ba9da450..00000000000 --- a/src/renderer/src/components/editor/diff-editor-line-number-options.ts +++ /dev/null @@ -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() - } - } -} diff --git a/src/renderer/src/components/editor/diff-editor-scrollbar-options.ts b/src/renderer/src/components/editor/diff-editor-scrollbar-options.ts deleted file mode 100644 index 0891359332b..00000000000 --- a/src/renderer/src/components/editor/diff-editor-scrollbar-options.ts +++ /dev/null @@ -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 diff --git a/src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts b/src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts deleted file mode 100644 index 793914d4799..00000000000 --- a/src/renderer/src/components/editor/diff-editor-whitespace-options.test.ts +++ /dev/null @@ -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 }) - }) -}) diff --git a/src/renderer/src/components/editor/diff-editor-whitespace-options.ts b/src/renderer/src/components/editor/diff-editor-whitespace-options.ts deleted file mode 100644 index 4c860fadeb7..00000000000 --- a/src/renderer/src/components/editor/diff-editor-whitespace-options.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { editor } from 'monaco-editor' - -export function buildDiffEditorWhitespaceOptions( - diffShowWhitespace: boolean | undefined -): Pick { - return { - // Why: Monaco defaults this to true, which hides indentation-only diffs. - ignoreTrimWhitespace: diffShowWhitespace !== true - } -} diff --git a/src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts b/src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts deleted file mode 100644 index 9d542cae07b..00000000000 --- a/src/renderer/src/components/editor/diff-editor-word-wrap-options.test.ts +++ /dev/null @@ -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' }) - }) -}) diff --git a/src/renderer/src/components/editor/diff-editor-word-wrap-options.ts b/src/renderer/src/components/editor/diff-editor-word-wrap-options.ts deleted file mode 100644 index c7f1e2cbea2..00000000000 --- a/src/renderer/src/components/editor/diff-editor-word-wrap-options.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { editor } from 'monaco-editor' - -export function buildDiffEditorWordWrapOptions( - diffWordWrap: boolean | undefined -): Pick { - return { - wordWrap: diffWordWrap === true ? 'on' : 'off' - } -} diff --git a/src/renderer/src/components/editor/diff-model-swap-view-state.test.ts b/src/renderer/src/components/editor/diff-model-swap-view-state.test.ts deleted file mode 100644 index 4185ab9aa35..00000000000 --- a/src/renderer/src/components/editor/diff-model-swap-view-state.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/src/renderer/src/components/editor/diff-model-swap-view-state.ts b/src/renderer/src/components/editor/diff-model-swap-view-state.ts deleted file mode 100644 index e46a1863c0d..00000000000 --- a/src/renderer/src/components/editor/diff-model-swap-view-state.ts +++ /dev/null @@ -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 - } - } -} diff --git a/src/renderer/src/components/editor/diff-navigation-context.test.tsx b/src/renderer/src/components/editor/diff-navigation-context.test.tsx index d2efce0bd36..cb64406a2a1 100644 --- a/src/renderer/src/components/editor/diff-navigation-context.test.tsx +++ b/src/renderer/src/components/editor/diff-navigation-context.test.tsx @@ -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 - disposeUpdate: ReturnType - 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() + } 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) - }) }) diff --git a/src/renderer/src/components/editor/diff-navigation-context.tsx b/src/renderer/src/components/editor/diff-navigation-context.tsx index 9bd1175de45..9e98d76e6a5 100644 --- a/src/renderer/src/components/editor/diff-navigation-context.tsx +++ b/src/renderer/src/components/editor/diff-navigation-context.tsx @@ -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({ - registerDiffEditor: noop, - unregisterDiffEditor: noop +// rerenders the header controls, not the heavy diff consumer. +const DiffNavigatorRegistrationContext = createContext({ + registerDiffNavigator: noop, + unregisterDiffNavigator: noop }) const DiffNavigationContext = createContext({ @@ -28,97 +36,104 @@ const DiffNavigationContext = createContext({ 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(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(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 ( - + {children} - + ) } -export function useDiffEditorRegistration(): DiffEditorRegistrationContextValue { - return useContext(DiffEditorRegistrationContext) +export function useDiffNavigatorRegistration(): DiffNavigatorRegistrationContextValue { + return useContext(DiffNavigatorRegistrationContext) } export function useDiffNavigation(): DiffNavigationContextValue { diff --git a/src/renderer/src/components/editor/diff-section-item-props.ts b/src/renderer/src/components/editor/diff-section-item-props.ts index e5ef73860c2..76e2c7c5e99 100644 --- a/src/renderer/src/components/editor/diff-section-item-props.ts +++ b/src/renderer/src/components/editor/diff-section-item-props.ts @@ -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>> setSections: Dispatch> - modifiedEditorsRef: MutableRefObject> handleSectionSaveRef: MutableRefObject<(index: number) => Promise> } diff --git a/src/renderer/src/components/editor/diff-section-live-render-limit.ts b/src/renderer/src/components/editor/diff-section-live-render-limit.ts index 01e6729fc84..2a4ad6ba1e4 100644 --- a/src/renderer/src/components/editor/diff-section-live-render-limit.ts +++ b/src/renderer/src/components/editor/diff-section-live-render-limit.ts @@ -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, diff --git a/src/renderer/src/components/editor/editor-shortcuts.test.ts b/src/renderer/src/components/editor/editor-shortcuts.test.ts index b8dd4cc1c48..7e5c6dc7b14 100644 --- a/src/renderer/src/components/editor/editor-shortcuts.test.ts +++ b/src/renderer/src/components/editor/editor-shortcuts.test.ts @@ -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 diff --git a/src/renderer/src/components/editor/editor-shortcuts.ts b/src/renderer/src/components/editor/editor-shortcuts.ts index d3ae6353e41..76db195875b 100644 --- a/src/renderer/src/components/editor/editor-shortcuts.ts +++ b/src/renderer/src/components/editor/editor-shortcuts.ts @@ -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) } diff --git a/src/renderer/src/components/editor/pierre-diff/PierreDiffProviders.tsx b/src/renderer/src/components/editor/pierre-diff/PierreDiffProviders.tsx new file mode 100644 index 00000000000..1cae8616d5f --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/PierreDiffProviders.tsx @@ -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 ( + + {children} + + ) +} diff --git a/src/renderer/src/components/editor/pierre-diff/PierreDiffSurface.tsx b/src/renderer/src/components/editor/pierre-diff/PierreDiffSurface.tsx new file mode 100644 index 00000000000..9d9c594be5f --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/PierreDiffSurface.tsx @@ -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 + /** 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 + 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({ 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>( + () => ({ + 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. +
+ + fileDiff={fileDiff} + options={options} + style={style} + edit={editEnabled} + editorOptions={editorOptions} + lineAnnotations={lineAnnotations} + renderAnnotation={renderAnnotation} + /> +
+ ) +} diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-active-element.ts b/src/renderer/src/components/editor/pierre-diff/pierre-diff-active-element.ts new file mode 100644 index 00000000000..8f4a9e73dd1 --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-active-element.ts @@ -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 +} diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-comment-annotations.tsx b/src/renderer/src/components/editor/pierre-diff/pierre-diff-comment-annotations.tsx new file mode 100644 index 00000000000..7be031b6623 --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-comment-annotations.tsx @@ -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 + +/** + * 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 + clearDeliveredDiffComments: ( + worktreeId: string, + comments: readonly DiffCommentDeliverySnapshot[] + ) => Promise + draftPlaceholder?: string + draftSubmitLabel?: string + onCancelDraft?: () => void + onSubmitDraft?: (body: string) => Promise + } +): React.ReactNode { + const data = annotation.metadata + if (!data) { + return null + } + if (data.kind === 'draft') { + return onSubmitDraft && onCancelDraft ? ( + + ) : null + } + const comment = data.comment + + return ( + onDeleteComment(comment.id)} + onSubmitEdit={ + onUpdateComment && comment.canEdit !== false + ? (body) => onUpdateComment(comment.id, body) + : undefined + } + headerActions={ + worktreeId && comment.author === undefined ? ( + void clearDeliveredDiffComments(worktreeId, notes)} + /> + ) : undefined + } + /> + ) +} diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-editor-provider.tsx b/src/renderer/src/components/editor/pierre-diff/pierre-diff-editor-provider.tsx new file mode 100644 index 00000000000..48248805529 --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-editor-provider.tsx @@ -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>( + (options: EditorOptions): DiffsEditor => new Editor(options), + [] + ) + + return {children} +} diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-metadata.ts b/src/renderer/src/components/editor/pierre-diff/pierre-diff-metadata.ts new file mode 100644 index 00000000000..669fd4b31af --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-metadata.ts @@ -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) +} diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-options.ts b/src/renderer/src/components/editor/pierre-diff/pierre-diff-options.ts new file mode 100644 index 00000000000..7ecd4cf921a --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-options.ts @@ -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({ + settings, + sideBySide, + collapsed +}: { + settings?: PierreDiffSettings | null + sideBySide: boolean + collapsed?: boolean +}): FileDiffOptions { + 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 +} diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-scroll.ts b/src/renderer/src/components/editor/pierre-diff/pierre-diff-scroll.ts new file mode 100644 index 00000000000..164187d0270 --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-scroll.ts @@ -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 +} diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-theme.ts b/src/renderer/src/components/editor/pierre-diff/pierre-diff-theme.ts new file mode 100644 index 00000000000..34957fcbbff --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-theme.ts @@ -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' +} diff --git a/src/renderer/src/components/editor/pierre-diff/pierre-diff-worker-pool.tsx b/src/renderer/src/components/editor/pierre-diff/pierre-diff-worker-pool.tsx new file mode 100644 index 00000000000..e40a2d701fb --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/pierre-diff-worker-pool.tsx @@ -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( + () => ({ workerFactory: createDiffHighlightWorker, poolSize: resolvePoolSize() }), + [] + ) + // Why: the pool owns `theme` for every component instance; per-file options are ignored. + const highlighterOptions = useMemo( + () => ({ theme: PIERRE_DIFF_THEMES }), + [] + ) + + return ( + + {children} + + ) +} diff --git a/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.ts b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.ts new file mode 100644 index 00000000000..41be67123ff --- /dev/null +++ b/src/renderer/src/components/editor/pierre-diff/use-pierre-diff-find.ts @@ -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) => 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) => { + 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 + } +} diff --git a/src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts b/src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts deleted file mode 100644 index 5d8c901c367..00000000000 --- a/src/renderer/src/components/editor/use-diff-section-model-lifecycle.ts +++ /dev/null @@ -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 } -} diff --git a/src/renderer/src/components/editor/useDiffSectionFallbackCleanup.ts b/src/renderer/src/components/editor/useDiffSectionFallbackCleanup.ts index d3268e57912..1a8c20dc075 100644 --- a/src/renderer/src/components/editor/useDiffSectionFallbackCleanup.ts +++ b/src/renderer/src/components/editor/useDiffSectionFallbackCleanup.ts @@ -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>> @@ -15,7 +13,6 @@ export function useDiffSectionFallbackCleanup({ useEffect(() => { if (isLargeDiffLimited) { setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - disposeDiffModels() } - }, [disposeDiffModels, index, isLargeDiffLimited, setSectionHeights]) + }, [index, isLargeDiffLimited, setSectionHeights]) } diff --git a/src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx b/src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx deleted file mode 100644 index a12ab4b3463..00000000000 --- a/src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.test.tsx +++ /dev/null @@ -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; 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 - isAttachedToEditor: () => boolean -} { - return { dispose: vi.fn(), isAttachedToEditor: () => false } -} - -function diffEditorFixture( - original: unknown, - modified: unknown -): { - current: editor.IStandaloneDiffEditor - setModel: ReturnType -} { - 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] - ) - }) -}) diff --git a/src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.ts b/src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.ts deleted file mode 100644 index 3bfb1bf18da..00000000000 --- a/src/renderer/src/components/editor/useDiffViewerLargeDiffLifecycle.ts +++ /dev/null @@ -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 - 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 -} diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx index aae2fffae75..66612c72867 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx @@ -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 virtualizer: Virtualizer sections: DiffSection[] - isDark: boolean settings: DiffSectionItemProps['settings'] sectionHeights: Record inlineReviewComments: DecoratedDiffComment[] @@ -82,7 +78,6 @@ export function PRFilesCombinedDiffBody({ getCommentableLineNumbers: (section: DiffSection) => readonly number[] | undefined setSectionHeights: React.Dispatch>> setSections: React.Dispatch> - modifiedEditorsRef: React.RefObject> handleSectionSaveRef: React.MutableRefObject<(index: number) => Promise> }): 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} />
diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx index d6da1177779..1e798dbac35 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx @@ -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(() => @@ -161,7 +158,6 @@ function PRFilesCombinedDiffSections({ const loadedIndicesRef = useRef>(new Set()) const loadingIndicesRef = useRef>(new Set()) const sectionsRef = useRef(sections) - const modifiedEditorsRef = useRef>(new Map()) const handleSectionSaveRef = useRef<(index: number) => Promise>(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 ( + + ) } diff --git a/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx b/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx index 68592865d47..6ac8f3aac39 100644 --- a/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx +++ b/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx @@ -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>(new Set()) const sectionsRef = useRef([]) const generationRef = useRef(0) - const modifiedEditorsRef = useRef>(new Map()) const handleSectionSaveRef = useRef<(index: number) => Promise>(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 ( +
@@ -377,5 +372,6 @@ export function PRFilesCombinedDiffViewer({
+ ) } diff --git a/src/renderer/src/lib/monaco-diff-editor-disposal.test.ts b/src/renderer/src/lib/monaco-diff-editor-disposal.test.ts deleted file mode 100644 index b8df5cf159b..00000000000 --- a/src/renderer/src/lib/monaco-diff-editor-disposal.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/renderer/src/lib/monaco-diff-editor-disposal.ts b/src/renderer/src/lib/monaco-diff-editor-disposal.ts deleted file mode 100644 index 262bd33d79d..00000000000 --- a/src/renderer/src/lib/monaco-diff-editor-disposal.ts +++ /dev/null @@ -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) => - guardMonacoDiffEditorDispose(createDiffEditor(...args), reportError)) as CreateDiffEditor - editorNamespace.__orcaDiffEditorFactoryGuardInstalled = true -} diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index 523ecf8e77b..642bc2bd5d4 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -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 diff --git a/src/renderer/src/lib/scroll-cache.test.ts b/src/renderer/src/lib/scroll-cache.test.ts index 18ecd33bfdc..8e328de9716 100644 --- a/src/renderer/src/lib/scroll-cache.test.ts +++ b/src/renderer/src/lib/scroll-cache.test.ts @@ -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 ? 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) }) }) diff --git a/src/renderer/src/lib/scroll-cache.ts b/src/renderer/src/lib/scroll-cache.ts index de28893ebaa..8e21e9e01a0 100644 --- a/src/renderer/src/lib/scroll-cache.ts +++ b/src/renderer/src/lib/scroll-cache.ts @@ -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() export type PdfViewPosition = { pageNumber: number; top: number; left: number } export const pdfViewPositionCache = new Map() -// 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() +// 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()