diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index be21362b47..5ec23f198b 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -1,4 +1,4 @@ -/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns scroll sideways on their own; wrapped ones share rows so the sides stay aligned. */ +/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll sideways together; wrapped ones share rows so the sides stay aligned. */ .root { display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } .header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } .selector { flex: 0 1 auto; min-width: 0; } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 04e845aacb..35a4350e6c 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -3,8 +3,8 @@ * selected file's turn-start and turn-end comparison drawn unified or side by * side, wrapped or scrolling, and controls to open the file itself. */ -import { useEffect, useMemo, useState } from 'react' -import type { ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode, UIEvent } from 'react' import { Button, IconChevronDownOutline14, IconCodeOutline16, IconPanelLeftOutline16, IconRightUpOutline16, IconWrapLinesOutline16, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -271,16 +271,24 @@ function hunkHeader(hunk: WorkspaceDiffHunk): string { } /** - * The side-by-side view without wrapping: two columns that scroll sideways on - * their own, so a long line on one side never runs under the other. Every - * line is one fixed-height row, which keeps the sides aligned. + * The side-by-side view without wrapping: two columns that clip their long + * lines and scroll sideways together, so a long line on one side never runs + * under the other and both sides show the same columns of text. Every line is + * one fixed-height row, which keeps the sides aligned. */ function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): ReactNode { const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) + const columns = useRef>({ left: null, right: null }) + // Mirror one side's horizontal offset onto the other; the mirrored side's own scroll event then finds nothing to change. + const follow = (side: 'left' | 'right') => (event: UIEvent): void => { + const other = columns.current[side === 'left' ? 'right' : 'left'] + if (other !== null && other.scrollLeft !== event.currentTarget.scrollLeft) other.scrollLeft = event.currentTarget.scrollLeft + } return (
{(['left', 'right'] as const).map(side => ( -
+
{ columns.current[side] = element }} onScroll={follow(side)}> {paired.map((hunk, position) => (
{hunk.header}
diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index c63b0b2910..84e76ddc3a 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -238,6 +238,14 @@ describe('ReviewTab', () => { expect(side('left').map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'context', 'del', 'del']) expect(side('left').map(line => line.textContent)).toEqual(['1a', '2b', '', '3d', '10x', '20z']) expect(side('right').map(line => line.textContent)).toEqual(['1a', '2B', '3c', '4d', '11y', '']) + // The two sides scroll sideways together, whichever side the reader drags. + const [left, right] = ['left', 'right'].map(name => view.container.querySelector(`[data-diff-side="${name}"]`) as HTMLDivElement) + fireEvent.scroll(left!, { target: { scrollLeft: 40 } }) + expect(right!.scrollLeft).toBe(40) + fireEvent.scroll(right!, { target: { scrollLeft: 15 } }) + expect(left!.scrollLeft).toBe(15) + fireEvent.scroll(right!, { target: { scrollLeft: 15 } }) + expect(left!.scrollLeft).toBe(15) expect(view.getByRole('button', { name: en['review.splitAria'] }).getAttribute('aria-pressed')).toBe('true') // Wrapped lines vary in height, so both sides share one row per pair. fireEvent.click(view.getByRole('button', { name: en['review.wrapAria'] }))