diff --git a/src/renderer/src/components/editor/DiffSectionBody.tsx b/src/renderer/src/components/editor/DiffSectionBody.tsx
index 78d2a7ff896..bb84a916ffe 100644
--- a/src/renderer/src/components/editor/DiffSectionBody.tsx
+++ b/src/renderer/src/components/editor/DiffSectionBody.tsx
@@ -14,6 +14,7 @@ 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'
+import { installDiffEditorShiftWheelScroll } from './diff-editor-shift-wheel-scroll'
const ImageDiffViewer = lazy(() => import('./ImageDiffViewer'))
@@ -77,6 +78,11 @@ export function DiffSectionBody({
onMount
}: DiffSectionBodyProps): React.JSX.Element {
const renderLimit = section.largeDiffRenderLimit?.limited ? section.largeDiffRenderLimit : null
+ const handleEditorMount: DiffOnMount = (editor, monaco) => {
+ const cleanupShiftWheelScroll = installDiffEditorShiftWheelScroll(editor)
+ editor.onDidDispose(cleanupShiftWheelScroll)
+ onMount(editor, monaco)
+ }
return (
void>>
+ getScrollLeft: () => number
+ getContainerDomNode: () => HTMLElement
+ getScrollWidth: () => number
+ getLayoutInfo: () => { contentWidth: number }
+}
+
+function createPaneFixture(initialScrollLeft = 10, scrollWidth = 1000): PaneFixture {
+ const container = document.createElement('div')
+ const input = document.createElement('div')
+ let scrollLeft = initialScrollLeft
+ const setScrollLeft = vi.fn((value: number) => {
+ scrollLeft = value
+ })
+ Object.defineProperty(container, 'clientWidth', { value: 200 })
+ container.appendChild(input)
+ document.body.appendChild(container)
+ return {
+ container,
+ input,
+ setScrollLeft,
+ getScrollLeft: () => scrollLeft,
+ getContainerDomNode: () => container,
+ getScrollWidth: () => scrollWidth,
+ getLayoutInfo: () => ({ contentWidth: 200 })
+ }
+}
+
+function dispatchWheel(target: HTMLElement, init: WheelEventInit): WheelEvent {
+ const event = new WheelEvent('wheel', { ...init, bubbles: true, cancelable: true })
+ // Happy DOM's WheelEvent omits mouse modifier fields.
+ Object.defineProperty(event, 'shiftKey', { value: init.shiftKey ?? false })
+ target.dispatchEvent(event)
+ return event
+}
+
+afterEach(() => {
+ document.body.replaceChildren()
+})
+
+describe('installDiffEditorShiftWheelScroll', () => {
+ it.each([
+ { label: 'vertical pixel input', init: { deltaY: 24 }, expected: 34 },
+ { label: 'platform-converted horizontal input', init: { deltaX: 12 }, expected: 22 },
+ {
+ label: 'line-based input',
+ init: { deltaY: -2, deltaMode: WheelEvent.DOM_DELTA_LINE },
+ expected: -22
+ },
+ {
+ label: 'page-based input',
+ init: { deltaY: 1, deltaMode: WheelEvent.DOM_DELTA_PAGE },
+ expected: 210
+ }
+ ])('scrolls the pane under the pointer for $label', ({ init, expected }) => {
+ const original = createPaneFixture()
+ const modified = createPaneFixture()
+ const onDownstreamWheel = vi.fn()
+ original.input.addEventListener('wheel', onDownstreamWheel)
+ const dispose = installDiffEditorShiftWheelScroll({
+ getOriginalEditor: () => original,
+ getModifiedEditor: () => modified
+ })
+
+ const event = dispatchWheel(original.input, { ...init, shiftKey: true })
+
+ expect(event.defaultPrevented).toBe(true)
+ expect(original.setScrollLeft).toHaveBeenCalledWith(expected)
+ expect(modified.setScrollLeft).not.toHaveBeenCalled()
+ expect(onDownstreamWheel).not.toHaveBeenCalled()
+ dispose()
+ })
+
+ it('leaves ordinary vertical wheel input for the outer combined-diff scroller', () => {
+ const original = createPaneFixture()
+ const modified = createPaneFixture()
+ const onDownstreamWheel = vi.fn()
+ original.input.addEventListener('wheel', onDownstreamWheel)
+ const dispose = installDiffEditorShiftWheelScroll({
+ getOriginalEditor: () => original,
+ getModifiedEditor: () => modified
+ })
+
+ const event = dispatchWheel(original.input, { deltaY: 24 })
+
+ expect(event.defaultPrevented).toBe(false)
+ expect(original.setScrollLeft).not.toHaveBeenCalled()
+ expect(onDownstreamWheel).toHaveBeenCalledTimes(1)
+ dispose()
+ })
+
+ it('leaves shift input alone when the pane has no horizontal overflow', () => {
+ const original = createPaneFixture(0, 200)
+ const modified = createPaneFixture(0, 200)
+ const onDownstreamWheel = vi.fn()
+ original.input.addEventListener('wheel', onDownstreamWheel)
+ const dispose = installDiffEditorShiftWheelScroll({
+ getOriginalEditor: () => original,
+ getModifiedEditor: () => modified
+ })
+
+ const event = dispatchWheel(original.input, { deltaY: 24, shiftKey: true })
+
+ expect(event.defaultPrevented).toBe(false)
+ expect(original.setScrollLeft).not.toHaveBeenCalled()
+ expect(onDownstreamWheel).toHaveBeenCalledTimes(1)
+ dispose()
+ })
+
+ // Monaco syncs pane scroll itself; this covers listener routing, not product-level pane independence.
+ it('routes the wheel event to the pane under the pointer', () => {
+ const original = createPaneFixture()
+ const modified = createPaneFixture()
+ const dispose = installDiffEditorShiftWheelScroll({
+ getOriginalEditor: () => original,
+ getModifiedEditor: () => modified
+ })
+
+ const event = dispatchWheel(modified.input, { deltaY: 24, shiftKey: true })
+
+ expect(event.defaultPrevented).toBe(true)
+ expect(modified.setScrollLeft).toHaveBeenCalledWith(34)
+ expect(original.setScrollLeft).not.toHaveBeenCalled()
+ dispose()
+ })
+
+ it('removes both pane listeners when disposed', () => {
+ const original = createPaneFixture()
+ const modified = createPaneFixture()
+ const dispose = installDiffEditorShiftWheelScroll({
+ getOriginalEditor: () => original,
+ getModifiedEditor: () => modified
+ })
+ dispose()
+
+ const originalEvent = dispatchWheel(original.input, { deltaY: 24, shiftKey: true })
+ const modifiedEvent = dispatchWheel(modified.input, { deltaY: 24, shiftKey: true })
+
+ expect(originalEvent.defaultPrevented).toBe(false)
+ expect(modifiedEvent.defaultPrevented).toBe(false)
+ expect(original.setScrollLeft).not.toHaveBeenCalled()
+ expect(modified.setScrollLeft).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/editor/diff-editor-shift-wheel-scroll.ts b/src/renderer/src/components/editor/diff-editor-shift-wheel-scroll.ts
new file mode 100644
index 00000000000..7f0b997c1d2
--- /dev/null
+++ b/src/renderer/src/components/editor/diff-editor-shift-wheel-scroll.ts
@@ -0,0 +1,64 @@
+import type { editor } from 'monaco-editor'
+
+const WHEEL_LINE_PIXELS = 16
+
+type HorizontalScrollEditor = Pick<
+ editor.ICodeEditor,
+ 'getContainerDomNode' | 'getScrollLeft' | 'setScrollLeft' | 'getScrollWidth'
+> & { getLayoutInfo: () => Pick }
+
+type DiffEditorWithPanes = {
+ getModifiedEditor: () => HorizontalScrollEditor
+ getOriginalEditor: () => HorizontalScrollEditor
+}
+
+function getHorizontalWheelPixels(event: WheelEvent, pageWidth: number): number {
+ const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
+ if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
+ return delta * WHEEL_LINE_PIXELS
+ }
+ if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
+ return delta * pageWidth
+ }
+ return delta
+}
+
+function canScrollHorizontally(editor: HorizontalScrollEditor): boolean {
+ return editor.getScrollWidth() > editor.getLayoutInfo().contentWidth
+}
+
+function installPaneShiftWheelScroll(editor: HorizontalScrollEditor): () => void {
+ const container = editor.getContainerDomNode()
+ const handleWheel = (event: WheelEvent): void => {
+ if (event.defaultPrevented || !event.shiftKey) {
+ return
+ }
+
+ // Why: a word-wrapped pane never overflows sideways, so leave the gesture to the outer list.
+ if (!canScrollHorizontally(editor)) {
+ return
+ }
+
+ const delta = getHorizontalWheelPixels(event, container.clientWidth)
+ if (delta === 0) {
+ return
+ }
+
+ // Why: combined diffs disable Monaco wheel handling so vertical input can reach the outer list.
+ event.preventDefault()
+ event.stopPropagation()
+ editor.setScrollLeft(editor.getScrollLeft() + delta)
+ }
+
+ container.addEventListener('wheel', handleWheel, { capture: true, passive: false })
+ return () => container.removeEventListener('wheel', handleWheel, true)
+}
+
+export function installDiffEditorShiftWheelScroll(editor: DiffEditorWithPanes): () => void {
+ const cleanupOriginal = installPaneShiftWheelScroll(editor.getOriginalEditor())
+ const cleanupModified = installPaneShiftWheelScroll(editor.getModifiedEditor())
+ return () => {
+ cleanupOriginal()
+ cleanupModified()
+ }
+}