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
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()