feat(diffs): render diffs with @pierre/diffs instead of Monaco

Replaces the per-file Monaco DiffEditor across every diff surface with
@pierre/diffs, which renders a CSS-grid row list and highlights in a Shiki
worker pool instead of constructing an editor per visible file.

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