fix(web): preserve split diff scroll offsets across clamped peers

This commit is contained in:
creatixchu committed 2026-09-18 15:17:13 +08:00
1 parent 79677e4824
commit b5cbcad854
7 files changed
+63 -6

No files matched your search

+37
View File
@@ -157,6 +157,43 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' })
await expect.poll(() => drawn(review.locator('[data-diff-side="left"]'))).toEqual(['del:1# 示例项目', 'context:2', 'context:3一个用于演示的仓库。'])
expect(await drawn(review.locator('[data-diff-side="right"]'))).toEqual(['del:1# 项目说明', 'context:2', 'context:3一个用于演示的仓库。'])
// Constrain the recorded three-row diff to exercise unequal horizontal ranges and classic scrollbars.
const scrollLayout = await page.addStyleTag({ content: `
[data-changes-review] { height: 120px !important; }
[data-diff-side] { scrollbar-width: auto; }
[data-diff-side]::-webkit-scrollbar { width: 16px; height: 16px; }
[data-diff-side="left"] { width: 65px; }
` })
try {
const left = review.locator('[data-diff-side="left"]')
const right = review.locator('[data-diff-side="right"]')
const metrics = (side: typeof left) => side.evaluate(element => ({
x: element.scrollLeft, y: element.scrollTop,
maxX: element.scrollWidth - element.clientWidth,
maxY: element.scrollHeight - element.clientHeight,
bottom: element.lastElementChild!.getBoundingClientRect().bottom,
}))
const initialLeft = await metrics(left)
const initialRight = await metrics(right)
expect(initialLeft.maxX).toBeGreaterThan(0)
expect(initialRight.maxX).toBe(0)
expect(initialLeft.maxY).toBeGreaterThan(0)
expect(initialLeft.maxY).toBe(initialRight.maxY)
await left.evaluate((element) => { element.scrollLeft = element.scrollWidth })
await expect.poll(async () => (await metrics(left)).x).toBe(initialLeft.maxX)
await left.evaluate((element) => { element.scrollTop = element.scrollHeight })
await expect.poll(async () => (await metrics(right)).y).toBe(initialLeft.maxY)
// Two frames allow the browser-generated peer event to run before checking the source.
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
expect((await metrics(left)).x).toBe(initialLeft.maxX)
expect((await metrics(left)).y).toBe(initialLeft.maxY)
expect((await metrics(left)).bottom).toBeCloseTo((await metrics(right)).bottom, 1)
await right.evaluate((element) => { element.scrollTop = 0 })
await expect.poll(async () => (await metrics(left)).y).toBe(0)
expect((await metrics(left)).x).toBe(initialLeft.maxX)
} finally {
await scrollLayout.evaluate(element => element.remove())
}
await review.getByRole('button', { name: '自动换行' }).click()
await review.locator('[data-review-view][data-review-wrap]').waitFor({ state: 'visible' })
await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。'])
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
README.md: 393fc8919bf64d71f5b61628871358aac0fc53d5
README.zh.md: 1deeb28a04d7bfe07403559c14eb19da021b7e65
README.md: a5d333353bd9c342be0549c70773ed38123e0ba8
README.zh.md: 9704b73e5377491d6379bf8cf3424b4f30b2fda0
@@ -44,6 +44,8 @@ The card renders the summary the Host serves for the turn's latest `workspace/ch
A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison; the selected file's counts follow it. The header's tools switch between the unified view and a side-by-side view that pairs each run of deletions with the additions that follow it, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, additions and deletions in the success and error colours, and a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content.
Without wrapping, the two columns synchronize vertical scrolling and horizontal offsets up to each side’s available range. Both horizontal scrollbars remain at the bottom of the visible comparison; scrolling vertically preserves a long line’s horizontal offset when the other side fits without horizontal scrolling.
### Inline-code links
The closing prose links produced or delivered paths: an inline-code token resolves by exact path, or by being exactly the basename of exactly one such path — a basename two paths share stays inert rather than guessing, so a mention can never open the wrong file. A resolved mention keeps its code chip and takes the markdown sheet's link language, with the full path as its title.
@@ -44,6 +44,8 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比;所选文件的行数跟在后面。头部的工具在单栏视图和左右视图之间切换,后者把每一段删除与紧随其后的新增逐行配对;切换自动换行;在 Sidebar 中打开当前整个文件;Host 有桌面时用默认应用打开它,等待与可重试错误状态与卡片一致。视图与换行的选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,新增与删除分别用成功色和错误色,并在文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。
关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。
### 行内代码链接
收尾正文链接产出或已交付的路径:行内代码 token 按精确路径解析,或当它恰好等于其中某条路径的 basename 且该路径唯一时解析——两条路径共享同一 basename 时保持不可点击而不作猜测,因此提及绝不打开错误的文件。解析成功的提及保留代码标签,并采用 Markdown 样式表的链接样式,完整路径作为其 `title`。
@@ -22,7 +22,7 @@
.status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); }
.body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); }
.columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; }
.column { min-width: 0; overflow-x: auto; }
.column { min-width: 0; overflow-x: scroll; }
.column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); }
.sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; }
.note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); }
@@ -280,11 +280,19 @@ function hunkHeader(hunk: WorkspaceDiffHunk): string {
function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): ReactNode {
const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks])
const columns = useRef<Record<'left' | 'right', HTMLDivElement | null>>({ left: null, right: null })
// Mirror one side's offsets onto the other; the mirrored side's own scroll event then finds nothing to change.
const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } })
const follow = (side: 'left' | 'right') => (event: UIEvent<HTMLDivElement>): void => {
const other = columns.current[side === 'left' ? 'right' : 'left']
const peer = side === 'left' ? 'right' : 'left'
const other = columns.current[peer]
for (const axis of ['scrollLeft', 'scrollTop'] as const) {
if (other !== null && other[axis] !== event.currentTarget[axis]) other[axis] = event.currentTarget[axis]
const value = event.currentTarget[axis]
if (offsets.current[side][axis] === value) continue
offsets.current[side][axis] = value
if (other !== null) {
other[axis] = value
// Record the browser-clamped offset so its scroll event cannot pull the source back.
offsets.current[peer][axis] = other[axis]
}
}
}
return (
@@ -248,6 +248,14 @@ describe('ReviewTab', () => {
expect([left!.scrollLeft, left!.scrollTop]).toEqual([15, 22])
fireEvent.scroll(right!, { target: { scrollLeft: 15, scrollTop: 22 } })
expect([left!.scrollLeft, left!.scrollTop]).toEqual([15, 22])
// A short peer clamps horizontal writes to zero, including the resulting scroll event.
Object.defineProperty(right, 'scrollLeft', { configurable: true, get: () => 0, set: () => {} })
fireEvent.scroll(left!, { target: { scrollLeft: 100 } })
fireEvent.scroll(right!)
expect(left!.scrollLeft).toBe(100)
fireEvent.scroll(left!, { target: { scrollTop: 44 } })
fireEvent.scroll(right!)
expect([left!.scrollLeft, right!.scrollTop]).toEqual([100, 44])
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'] }))